---
phase: 04
plan: 09
title: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views
status: pending
wave: 3
depends_on:
  - 04-02
  - 04-04
  - 04-05
  - 04-06
  - 04-07
files_modified:
  - SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs
  - SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs
  - SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs
  - SharepointToolbox/Views/Tabs/BulkMembersView.xaml
  - SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs
  - SharepointToolbox/Views/Tabs/BulkSitesView.xaml
  - SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs
  - SharepointToolbox/Views/Tabs/FolderStructureView.xaml
  - SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs
autonomous: true
requirements:
  - BULK-02
  - BULK-03
  - BULK-04
  - BULK-05
  - FOLD-01
  - FOLD-02

must_haves:
  truths:
    - "All three CSV tabs follow the same flow: Import CSV -> Validate -> Preview DataGrid -> Confirm -> Execute"
    - "Each DataGrid preview shows valid/invalid row indicators"
    - "Invalid rows highlighted — user can fix and re-import before executing"
    - "Confirmation dialog shown before execution"
    - "Retry Failed button appears after partial failures"
    - "Failed-items CSV export available after any failure"
    - "Load Example button loads bundled CSV from embedded resources"
  artifacts:
    - path: "SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs"
      provides: "Bulk Members tab ViewModel"
      exports: ["BulkMembersViewModel"]
    - path: "SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs"
      provides: "Bulk Sites tab ViewModel"
      exports: ["BulkSitesViewModel"]
    - path: "SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs"
      provides: "Folder Structure tab ViewModel"
      exports: ["FolderStructureViewModel"]
    - path: "SharepointToolbox/Views/Tabs/BulkMembersView.xaml"
      provides: "Bulk Members tab UI"
    - path: "SharepointToolbox/Views/Tabs/BulkSitesView.xaml"
      provides: "Bulk Sites tab UI"
    - path: "SharepointToolbox/Views/Tabs/FolderStructureView.xaml"
      provides: "Folder Structure tab UI"
  key_links:
    - from: "BulkMembersViewModel.cs"
      to: "IBulkMemberService.AddMembersAsync"
      via: "RunOperationAsync override"
      pattern: "AddMembersAsync"
    - from: "BulkSitesViewModel.cs"
      to: "IBulkSiteService.CreateSitesAsync"
      via: "RunOperationAsync override"
      pattern: "CreateSitesAsync"
    - from: "FolderStructureViewModel.cs"
      to: "IFolderStructureService.CreateFoldersAsync"
      via: "RunOperationAsync override"
      pattern: "CreateFoldersAsync"
---

# Plan 04-09: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views

## Goal

Create all three CSV-based bulk operation tabs (Bulk Members, Bulk Sites, Folder Structure). Each follows the same flow: Import CSV -> Validate via CsvValidationService -> Preview in DataGrid -> Confirm via ConfirmBulkOperationDialog -> Execute via respective service -> Report results. Includes Retry Failed button and failed-items CSV export.

## Context

Services: `IBulkMemberService` (04-04), `IBulkSiteService` (04-05), `IFolderStructureService` (04-06), `ICsvValidationService` (04-02). Shared UI: `ConfirmBulkOperationDialog` (04-07), `BulkResultCsvExportService` (04-01). Localization keys from Plan 04-07.

All three ViewModels follow FeatureViewModelBase pattern. The CSV import flow is identical across all three — only the row model, validation, and service call differ.

Example CSVs are embedded resources accessed via `Assembly.GetExecutingAssembly().GetManifestResourceStream()`.

## Tasks

### Task 1: Create BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel

**Files:**
- `SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs`
- `SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs`
- `SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs`

**Action:**

1. Create `BulkMembersViewModel.cs`:
```csharp
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class BulkMembersViewModel : FeatureViewModelBase
{
    private readonly IBulkMemberService _memberService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<BulkMemberRow>? _validRows;
    private List<BulkMemberRow>? _failedRowsForRetry;
    private BulkOperationSummary<BulkMemberRow>? _lastResult;

    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<BulkMemberRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<BulkMemberRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }
    public IAsyncRelayCommand RetryFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public BulkMembersViewModel(
        IBulkMemberService memberService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _memberService = memberService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
        RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("bulk_add_members.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateMembers(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<BulkMemberRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
        var invalidCount = rows.Count - _validRows.Count;
        PreviewSummary = string.Format(TranslationSource.Instance["bulkmembers.preview"],
            rows.Count, _validRows.Count, invalidCount);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows to process. Import a CSV first.");

        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{_validRows.Count} members will be added");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
        _lastResult = await _memberService.AddMembersAsync(ctx, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            _failedRowsForRetry = _lastResult.HasFailures
                ? _lastResult.FailedItems.Select(r => r.Item).ToList()
                : null;
            ExportFailedCommand.NotifyCanExecuteChanged();
            RetryFailedCommand.NotifyCanExecuteChanged();

            ResultSummary = _lastResult.HasFailures
                ? string.Format(TranslationSource.Instance["bulk.result.success"],
                    _lastResult.SuccessCount, _lastResult.FailedCount)
                : string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
                    _lastResult.TotalCount);
        });
    }

    private async Task RetryFailedAsync()
    {
        if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
        _validRows = _failedRowsForRetry;
        HasFailures = false;
        await RunCommand.ExecuteAsync(null);
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;
        var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_members.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
            Log.Information("Exported failed member rows to {Path}", dlg.FileName);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}
```

2. Create `BulkSitesViewModel.cs` — follows same pattern as BulkMembersViewModel but uses `IBulkSiteService` and `BulkSiteRow`:
```csharp
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class BulkSitesViewModel : FeatureViewModelBase
{
    private readonly IBulkSiteService _siteService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<BulkSiteRow>? _validRows;
    private List<BulkSiteRow>? _failedRowsForRetry;
    private BulkOperationSummary<BulkSiteRow>? _lastResult;

    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<BulkSiteRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<BulkSiteRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }
    public IAsyncRelayCommand RetryFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public BulkSitesViewModel(
        IBulkSiteService siteService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _siteService = siteService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
        RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("bulk_create_sites.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateSites(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<BulkSiteRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
        var invalidCount = rows.Count - _validRows.Count;
        PreviewSummary = string.Format(TranslationSource.Instance["bulksites.preview"],
            rows.Count, _validRows.Count, invalidCount);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows to process. Import a CSV first.");

        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{_validRows.Count} sites will be created");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
        _lastResult = await _siteService.CreateSitesAsync(ctx, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            _failedRowsForRetry = _lastResult.HasFailures
                ? _lastResult.FailedItems.Select(r => r.Item).ToList()
                : null;
            ExportFailedCommand.NotifyCanExecuteChanged();
            RetryFailedCommand.NotifyCanExecuteChanged();

            ResultSummary = _lastResult.HasFailures
                ? string.Format(TranslationSource.Instance["bulk.result.success"],
                    _lastResult.SuccessCount, _lastResult.FailedCount)
                : string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
                    _lastResult.TotalCount);
        });
    }

    private async Task RetryFailedAsync()
    {
        if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
        _validRows = _failedRowsForRetry;
        HasFailures = false;
        await RunCommand.ExecuteAsync(null);
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;
        var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_sites.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}
```

3. Create `FolderStructureViewModel.cs`:
```csharp
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class FolderStructureViewModel : FeatureViewModelBase
{
    private readonly IFolderStructureService _folderService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<FolderStructureRow>? _validRows;
    private BulkOperationSummary<string>? _lastResult;

    [ObservableProperty] private string _siteUrl = string.Empty;
    [ObservableProperty] private string _libraryTitle = string.Empty;
    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<FolderStructureRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<FolderStructureRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public FolderStructureViewModel(
        IFolderStructureService folderService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _folderService = folderService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("folder_structure.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateFolders(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<FolderStructureRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();

        var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
        PreviewSummary = string.Format(TranslationSource.Instance["folderstruct.preview"], uniquePaths.Count);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows. Import a CSV first.");
        if (string.IsNullOrWhiteSpace(SiteUrl))
            throw new InvalidOperationException("Site URL is required.");
        if (string.IsNullOrWhiteSpace(LibraryTitle))
            throw new InvalidOperationException("Library title is required.");

        var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{uniquePaths.Count} folders will be created in {LibraryTitle}");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var profile = new TenantProfile
        {
            Name = _currentProfile.Name,
            TenantUrl = SiteUrl,
            ClientId = _currentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);

        _lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            ExportFailedCommand.NotifyCanExecuteChanged();

            ResultSummary = _lastResult.HasFailures
                ? string.Format(TranslationSource.Instance["bulk.result.success"],
                    _lastResult.SuccessCount, _lastResult.FailedCount)
                : string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
                    _lastResult.TotalCount);
        });
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;
        var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_folders.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        SiteUrl = string.Empty;
        LibraryTitle = string.Empty;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}
```

**Verify:**
```bash
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
```

**Done:** All three ViewModels compile with Import CSV, Load Example, Validate, Preview, Confirm, Execute, Retry Failed, Export Failed flows.

### Task 2: Create BulkMembersView + BulkSitesView + FolderStructureView

**Files:**
- `SharepointToolbox/Views/Tabs/BulkMembersView.xaml`
- `SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs`
- `SharepointToolbox/Views/Tabs/BulkSitesView.xaml`
- `SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs`
- `SharepointToolbox/Views/Tabs/FolderStructureView.xaml`
- `SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs`

**Action:**

1. Create `BulkMembersView.xaml`:
```xml
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel Margin="10">
        <!-- Options Panel (Left) -->
        <StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.execute]}"
                    Command="{Binding RunCommand}" Margin="0,0,0,5"
                    IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
                    Command="{Binding CancelCommand}"
                    Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />

            <ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
                         Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
            <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />

            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
                    Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}" />
        </StackPanel>

        <!-- Preview DataGrid (Right) -->
        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
                                    Binding="{Binding Record.GroupName}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
                                    Binding="{Binding Record.Email}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
                                    Binding="{Binding Record.Role}" Width="80" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
```

Note: If `ListToStringConverter` doesn't exist, create one in `Views/Converters/` that joins a `List<string>` with "; ". Alternatively, the executor can use a simpler approach: bind to `Errors[0]` or create a `ValidationErrors` computed property on the view model row wrapper.

2. Create `BulkMembersView.xaml.cs`:
```csharp
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class BulkMembersView : UserControl
{
    public BulkMembersView(ViewModels.Tabs.BulkMembersViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message)
                { Owner = System.Windows.Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }
}
```

3. Create `BulkSitesView.xaml` — same layout as BulkMembersView but with site-specific columns:
```xml
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel Margin="10">
        <StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.execute]}"
                    Command="{Binding RunCommand}" Margin="0,0,0,5"
                    IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
                    Command="{Binding CancelCommand}"
                    Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />

            <ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
                         Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
            <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />

            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
                    Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}" />
        </StackPanel>

        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
                                    Binding="{Binding Record.Name}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
                                    Binding="{Binding Record.Alias}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.type]}"
                                    Binding="{Binding Record.Type}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
                                    Binding="{Binding Record.Owners}" Width="*" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
```

4. Create `BulkSitesView.xaml.cs`:
```csharp
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class BulkSitesView : UserControl
{
    public BulkSitesView(ViewModels.Tabs.BulkSitesViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message)
                { Owner = System.Windows.Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }
}
```

5. Create `FolderStructureView.xaml`:
```xml
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel Margin="10">
        <StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0">
            <!-- Site URL and Library inputs -->
            <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.siteurl]}"
                       Margin="0,0,0,3" />
            <TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
                       Margin="0,0,0,3" />
            <TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.execute]}"
                    Command="{Binding RunCommand}" Margin="0,0,0,5"
                    IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
                    Command="{Binding CancelCommand}"
                    Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />

            <ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
                         Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
            <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />

            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}" />
        </StackPanel>

        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="Level 1" Binding="{Binding Record.Level1}" Width="*" />
                <DataGridTextColumn Header="Level 2" Binding="{Binding Record.Level2}" Width="*" />
                <DataGridTextColumn Header="Level 3" Binding="{Binding Record.Level3}" Width="*" />
                <DataGridTextColumn Header="Level 4" Binding="{Binding Record.Level4}" Width="*" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
```

6. Create `FolderStructureView.xaml.cs`:
```csharp
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class FolderStructureView : UserControl
{
    public FolderStructureView(ViewModels.Tabs.FolderStructureViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message)
                { Owner = System.Windows.Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }
}
```

**Important:** If `ListToStringConverter` does not exist in the project, create `SharepointToolbox/Views/Converters/ListToStringConverter.cs`:
```csharp
using System.Globalization;
using System.Windows.Data;

namespace SharepointToolbox.Views.Converters;

public class ListToStringConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is IEnumerable<string> list)
            return string.Join("; ", list);
        return string.Empty;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotImplementedException();
}
```

Register it in `App.xaml` Application.Resources alongside existing converters. Also register `EnumBoolConverter` if needed by TransferView:
```csharp
// In App.xaml or wherever converters are registered
```

**Verify:**
```bash
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
```

**Done:** All three CSV tab Views + code-behind compile. Each wires ConfirmBulkOperationDialog for confirmation. DataGrid shows preview with validation indicators. Import CSV, Load Example, Execute, Retry Failed, Export Failed all connected.

**Commit:** `feat(04-09): create BulkMembers, BulkSites, and FolderStructure ViewModels and Views`
