diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index 33d63a2..35e2d3f 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -7,6 +7,10 @@ Test Storage + + DMS + Dokumente + Upload diff --git a/Components/Pages/DmsDashboard.razor b/Components/Pages/DmsDashboard.razor new file mode 100644 index 0000000..9a3123c --- /dev/null +++ b/Components/Pages/DmsDashboard.razor @@ -0,0 +1,167 @@ +@page "/dms" +@using MudBlazor +@using Pldpro.Web.UI.Models +@inject Pldpro.Web.UI.Services.IDocumentClient Client +@inject NavigationManager Nav + +DMS + + + + + Dokumenten-Management + + + @foreach (var b in _buckets) + { + @b + } + + + Aktualisieren + + + Upload + + + Zur Liste + + + + + + + + Gesamt + @_total.ToString("N0") + + + + + Eingegangen + @_eingegangen.ToString("N0") + + + + + Freigegeben + @_freigegeben.ToString("N0") + + + + + Bezahlt + @_bezahlt.ToString("N0") + + + + + + + + Zuletzt hinzugefügt + + + + + Filtern + + + + + + Datei + Pfad + Größe + Geändert + + + + @context.FileName + @context.PathPrefix + @(context.Size?.ToString("N0")) + @context.LastModified + + + + + + + + + + + + + +@code { + private List _buckets = new(); + private string? _bucket; + + private string? _query; + private string? _prefix; + + private int _total; + private int _eingegangen; + private int _freigegeben; + private int _bezahlt; + + private List _latest = new(); + + protected override async Task OnInitializedAsync() + { + _buckets = (await Client.ListBucketsAsync()).ToList(); + _bucket = _buckets.FirstOrDefault(); + await Reload(); + } + + private async Task Reload() + { + if (string.IsNullOrWhiteSpace(_bucket)) + { + _total = _eingegangen = _freigegeben = _bezahlt = 0; + _latest = new(); + StateHasChanged(); + return; + } + + // Kennzahlen: einmal komplette Liste (für kleine Datenmengen ok) + var (allItems, total) = await Client.SearchAsync(_bucket!, _query, _prefix, 0, int.MaxValue); + _total = total; + + // Aktuell sind Status nur UI-intern; wenn später persistiert, hier echte Counts laden. + _eingegangen = allItems.Count(i => i.Status == DocumentStatus.Eingegangen); + _freigegeben = allItems.Count(i => i.Status == DocumentStatus.Freigegeben); + _bezahlt = allItems.Count(i => i.Status == DocumentStatus.Bezahlt); + + // Letzte 10 + var (latest, _) = await Client.SearchAsync(_bucket!, _query, _prefix, 0, 10); + _latest = latest + .OrderByDescending(i => i.LastModified ?? DateTime.MinValue) + .Take(10) + .ToList(); + + StateHasChanged(); + } + + private void Download(DocumentListItem item) + { + var url = Client.GetDownloadUrl(item.Bucket, item.Key); + Nav.NavigateTo(url, forceLoad: true); + } + + private static string EncodeKeyForPath(string key) + => string.Join("/", (key ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); +} \ No newline at end of file diff --git a/Components/Pages/DmsDetail.razor b/Components/Pages/DmsDetail.razor new file mode 100644 index 0000000..9c7c556 --- /dev/null +++ b/Components/Pages/DmsDetail.razor @@ -0,0 +1,159 @@ +@page "/dms/detail/{Bucket}/{*Key}" +@using MudBlazor +@using Pldpro.Web.UI.Models +@inject Pldpro.Web.UI.Services.IDocumentClient Client +@inject NavigationManager Nav +@inject IDialogService Dialogs +@inject ISnackbar Snackbar + +Dokument + +@if (_loading) +{ + + + +} +else if (_doc is null) +{ + + + Dokument nicht gefunden. + + Zurück + +} +else +{ + + + + @_doc.FileName + + + Zurück + + + + + + + + Vorschau + + + PDF‑Vorschau Platzhalter – später PDF.js/Viewer integrieren. + + + + + + + + Details + + + + + + + Feld + Wert + + + + Bucket@_doc.Bucket + Key@_doc.Key + Dateiname@_doc.FileName + Pfad@_doc.PathPrefix + Größe@(_doc.Size?.ToString("N0")) Bytes + Geändert@_doc.LastModified + + + + + + Download + + + + Löschen + + + + + + + +} + +@code { + [Parameter] public string Bucket { get; set; } = default!; + [Parameter] public string Key { get; set; } = default!; // Catch-all wird von Blazor decodiert + + private DocumentDetail? _doc; + private bool _loading; + + protected override async Task OnParametersSetAsync() + { + _loading = true; + try + { + _doc = await Client.GetAsync(Bucket, Key); + } + catch (Exception ex) + { + Snackbar.Add($"Fehler beim Laden: {ex.Message}", Severity.Error); + _doc = null; + } + finally + { + _loading = false; + } + } + + private void Back() => Nav.NavigateTo("/dms/list"); + + private Task Download() + { + if (_doc is null) return Task.CompletedTask; + var url = Client.GetDownloadUrl(_doc.Bucket, _doc.Key); + Nav.NavigateTo(url, forceLoad: true); + return Task.CompletedTask; + } + + private async Task Delete() + { + if (_doc is null) return; + + // Du kannst alternativ ShowMessageBox verwenden, wenn du keinen eigenen ConfirmDialog nutzen willst: + var confirm = await Dialogs.ShowMessageBox( + title: "Dokument löschen", + markupMessage: (MarkupString)$"Möchten Sie '{_doc.FileName}' endgültig löschen?", + yesText: "Löschen", + cancelText: "Abbrechen", + options: new DialogOptions { CloseOnEscapeKey = true }); + + if (confirm == true) + { + try + { + await Client.DeleteAsync(_doc.Bucket, _doc.Key); + Snackbar.Add("Dokument gelöscht.", Severity.Success); + Nav.NavigateTo("/dms/list"); + } + catch (Exception ex) + { + Snackbar.Add($"Löschen fehlgeschlagen: {ex.Message}", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Components/Pages/DmsList.razor b/Components/Pages/DmsList.razor new file mode 100644 index 0000000..2e9a9db --- /dev/null +++ b/Components/Pages/DmsList.razor @@ -0,0 +1,94 @@ +@page "/dms/list" +@using MudBlazor +@using Pldpro.Web.UI.Models +@inject Pldpro.Web.UI.Services.IDocumentClient Client +@inject NavigationManager Nav + + + + + @foreach (var b in _buckets) + { + @b + } + + + + + Suchen + Upload + + + + + + Datei + Bucket + Pfad + Größe + Geändert + + + + + + @context.FileName + @context.Bucket + @context.PathPrefix + @context.Size?.ToString("N0") + @context.LastModified + + + Details + + + + + + + + + + + Zurück + Seite @(_page + 1) + Weiter + + + +@code { + private List _buckets = new(); + private string? _bucket; + private string? _prefix; + private string? _query; + + private int _page = 0, _pageSize = 25, _total = 0; + private List _items = new(); + + protected override async Task OnInitializedAsync() + { + _buckets = (await Client.ListBucketsAsync()).ToList(); + _bucket = _buckets.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(_bucket)) + await Reload(); + } + + private async Task Reload() + { + if (string.IsNullOrWhiteSpace(_bucket)) return; + var (items, total) = await Client.SearchAsync(_bucket!, _query, _prefix, _page, _pageSize); + _items = items.ToList(); // <- Items ist List + _total = total; + } + + private async Task Prev() { if (_page > 0) { _page--; await Reload(); } } + private async Task Next() { if (((_page + 1) * _pageSize) < _total) { _page++; await Reload(); } } + + private static string EncodeKeyForPath(string key) + => string.Join("/", (key ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); +} \ No newline at end of file diff --git a/Components/Pages/DmsUpload.razor b/Components/Pages/DmsUpload.razor new file mode 100644 index 0000000..3900265 --- /dev/null +++ b/Components/Pages/DmsUpload.razor @@ -0,0 +1,94 @@ +@page "/dms/upload" +@using MudBlazor +@inject Pldpro.Web.UI.Services.IDocumentClient Client +@inject NavigationManager Nav +@inject ISnackbar Snackbar + +Upload + + + Dokument-Upload + + + + @foreach (var b in _buckets) + { + @b + } + + + + + + + + + @foreach (var msg in _messages) + { + @msg + } + + + + + + Zur Liste + + + Dashboard + + + + + +@code { + private List _buckets = new(); + private string? _bucket; + private string? _path; + + private RenderFragment? _result; + private const long StreamLimit = 512L * 1024 * 1024; + + protected override async Task OnInitializedAsync() + { + try + { + _buckets = (await Client.ListBucketsAsync()).ToList(); + _bucket = _buckets.FirstOrDefault(); + } + catch (Exception ex) + { + Snackbar.Add($"Buckets konnten nicht geladen werden: {ex.Message}", Severity.Error); + } + } + + + private readonly List _messages = new(); + + private async Task OnFilesSelected(InputFileChangeEventArgs e) + { + if (string.IsNullOrWhiteSpace(_bucket)) + { + Snackbar.Add("Bitte zuerst einen Bucket auswählen.", Severity.Warning); + return; + } + + foreach (var f in e.GetMultipleFiles()) + { + try + { + await Client.UploadAsync(_bucket!, _path, f, StreamLimit); + _messages.Add($"hochgeladen: {f.Name} ({f.Size:N0} Bytes)"); + } + catch (Exception ex) + { + _messages.Add($"Fehler bei '{f.Name}': {ex.Message}"); + } + } + + } +} \ No newline at end of file diff --git a/Components/shared/ConfirmDialog.razor b/Components/shared/ConfirmDialog.razor new file mode 100644 index 0000000..03b1c10 --- /dev/null +++ b/Components/shared/ConfirmDialog.razor @@ -0,0 +1,18 @@ +@using MudBlazor + + + @Title + @Message + + + Abbrechen + OK + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string Title { get; set; } = "Bestätigen"; + [Parameter] public string Message { get; set; } = "Sind Sie sicher?"; +} +`` \ No newline at end of file diff --git a/Components/shared/StatusChip.razor b/Components/shared/StatusChip.razor new file mode 100644 index 0000000..15730ce --- /dev/null +++ b/Components/shared/StatusChip.razor @@ -0,0 +1,32 @@ +@using Pldpro.Web.UI.Models +@using MudBlazor + + + @Text + + +@code { + [Parameter] public DocumentStatus Status { get; set; } + + private string Text => Status switch + { + DocumentStatus.Eingegangen => "Eingegangen", + DocumentStatus.Freigegeben => "Freigegeben", + DocumentStatus.Bezahlt => "Bezahlt", + _ => Status.ToString() + }; + + private Color Color => Status switch + { + DocumentStatus.Eingegangen => Color.Info, + DocumentStatus.Freigegeben => Color.Success, + DocumentStatus.Bezahlt => Color.Primary, + _ => Color.Default + }; +} \ No newline at end of file diff --git a/Models/UI/DocumentModels.cs b/Models/UI/DocumentModels.cs new file mode 100644 index 0000000..f9df059 --- /dev/null +++ b/Models/UI/DocumentModels.cs @@ -0,0 +1,20 @@ +namespace Pldpro.Web.UI.Models; + +public enum DocumentStatus { Eingegangen, Freigegeben, Bezahlt } + +public class DocumentListItem +{ + public string Bucket { get; set; } = string.Empty; + public string Key { get; set; } = string.Empty; // z.B. "rechnungen/2026/INV-123.pdf" + public string FileName => Key.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? Key; + public long? Size { get; set; } + public DateTime? LastModified { get; set; } + public DocumentStatus Status { get; set; } = DocumentStatus.Eingegangen; // (UI-only) + public string PathPrefix => Key.Contains('/') ? string.Join('/', Key.Split('/').SkipLast(1)) : string.Empty; +} + +public sealed class DocumentDetail : DocumentListItem +{ + // Platzhalter für spätere Rechnungsfelder + public string? Notes { get; set; } +} \ No newline at end of file diff --git a/Pldpro.Web.csproj b/Pldpro.Web.csproj index 66cc46a..1f34c56 100644 --- a/Pldpro.Web.csproj +++ b/Pldpro.Web.csproj @@ -17,4 +17,9 @@ + + + + + \ No newline at end of file diff --git a/Program.cs b/Program.cs index c386f72..2973f6a 100644 --- a/Program.cs +++ b/Program.cs @@ -61,7 +61,7 @@ builder.Services.AddSingleton(sp => // Domain-Service builder.Services.AddScoped(); - +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Services/UI/IDocumentClient.cs b/Services/UI/IDocumentClient.cs new file mode 100644 index 0000000..77c657d --- /dev/null +++ b/Services/UI/IDocumentClient.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Components.Forms; +using Pldpro.Web.UI.Models; + +namespace Pldpro.Web.UI.Services; + +public interface IDocumentClient +{ + Task> ListBucketsAsync(CancellationToken ct = default); + + Task<(IReadOnlyList Items, int Total)> SearchAsync( + string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default); + + Task GetAsync(string bucket, string key, CancellationToken ct = default); + + Task UploadAsync(string bucket, string? pathPrefix, IBrowserFile file, long streamLimit, CancellationToken ct = default); + + Task DeleteAsync(string bucket, string key, CancellationToken ct = default); + + string GetDownloadUrl(string bucket, string key); +} diff --git a/Services/UI/StorageDocumentClient.cs b/Services/UI/StorageDocumentClient.cs new file mode 100644 index 0000000..c523e59 --- /dev/null +++ b/Services/UI/StorageDocumentClient.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Components.Forms; +using Pldpro.Web.UI.Models; +using System.Net.Http.Json; + +namespace Pldpro.Web.UI.Services; + +public sealed class StorageDocumentClient(IHttpClientFactory factory) : IDocumentClient +{ + private readonly HttpClient _http = factory.CreateClient("AppApi"); + + private sealed record BucketVm(string Name, DateTime? CreationDate); + private sealed record ObjectVm(string Key, long? Size, DateTime? LastModified); + + public async Task> ListBucketsAsync(CancellationToken ct = default) + { + var list = await _http.GetFromJsonAsync>("/api/storage/buckets", ct) ?? new(); + return list.Select(b => b.Name).ToList(); + } + + public async Task<(IReadOnlyList Items, int Total)> SearchAsync( + string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default) + { + var objs = await _http.GetFromJsonAsync>($"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/objects", ct) ?? new(); + + IEnumerable q = objs.Select(o => new DocumentListItem + { + Bucket = bucket, + Key = o.Key, + Size = o.Size, + LastModified = o.LastModified + }); + + if (!string.IsNullOrWhiteSpace(pathPrefix)) + q = q.Where(d => d.Key.StartsWith(pathPrefix!.Trim('/') + "/", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(query)) + { + q = q.Where(d => + d.FileName.Contains(query, StringComparison.OrdinalIgnoreCase) || + d.PathPrefix.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + var total = q.Count(); + var pageItems = q + .OrderByDescending(d => d.LastModified ?? DateTime.MinValue) + .Skip(page * pageSize) + .Take(pageSize) + .ToList(); + + return (pageItems, total); + } + + public async Task GetAsync(string bucket, string key, CancellationToken ct = default) + { + // Da es keinen Einzel-Endpoint gibt, holen wir die Liste und picken das Objekt. + var (items, _) = await SearchAsync(bucket, null, null, 0, int.MaxValue, ct); + var d = items.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.Ordinal)); + return d is null ? null : new DocumentDetail + { + Bucket = d.Bucket, + Key = d.Key, + Size = d.Size, + LastModified = d.LastModified, + Status = d.Status + }; + } + + public async Task UploadAsync(string bucket, string? pathPrefix, IBrowserFile file, long streamLimit, CancellationToken ct = default) + { + using var stream = file.OpenReadStream(streamLimit); + using var content = new MultipartFormDataContent(); + content.Add(new StreamContent(stream), "file", file.Name); + if (!string.IsNullOrWhiteSpace(pathPrefix)) + content.Add(new StringContent(pathPrefix!.Trim('/')), "path"); + + var resp = await _http.PostAsync($"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/upload", content, ct); + resp.EnsureSuccessStatusCode(); + } + + public async Task DeleteAsync(string bucket, string key, CancellationToken ct = default) + { + var url = $"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/objects/{EncodeKeyForPath(key)}"; + var resp = await _http.DeleteAsync(url, ct); + resp.EnsureSuccessStatusCode(); + } + + public string GetDownloadUrl(string bucket, string key) + => $"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/download/{EncodeKeyForPath(key)}"; + + // Pfadsegment-weise encodieren: Slashes bleiben Trennzeichen + private static string EncodeKeyForPath(string key) + => string.Join("/", (key ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); +} \ No newline at end of file