DMS Layout mit Fehlern
All checks were successful
Build & Deploy PLDpro.Web Test to 192.168.1.100 / build-and-deploy (push) Successful in 1m15s
All checks were successful
Build & Deploy PLDpro.Web Test to 192.168.1.100 / build-and-deploy (push) Successful in 1m15s
This commit is contained in:
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
<MudNavLink Href="test" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">Test</MudNavLink>
|
<MudNavLink Href="test" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">Test</MudNavLink>
|
||||||
<MudNavLink Href="storage" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Cloud">Storage</MudNavLink>
|
<MudNavLink Href="storage" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Cloud">Storage</MudNavLink>
|
||||||
|
|
||||||
|
<MudNavLink Href="/dms" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">DMS</MudNavLink>
|
||||||
|
<MudNavLink Href="/dms/list" Icon="@Icons.Material.Filled.List">Dokumente</MudNavLink>
|
||||||
|
<MudNavLink Href="/dms/upload" Icon="@Icons.Material.Filled.Upload">Upload</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
167
Components/Pages/DmsDashboard.razor
Normal file
167
Components/Pages/DmsDashboard.razor
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
@page "/dms"
|
||||||
|
@using MudBlazor
|
||||||
|
@using Pldpro.Web.UI.Models
|
||||||
|
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageTitle>DMS</PageTitle>
|
||||||
|
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<!-- Kopfzeile: Titel, Bucket-Auswahl und Aktionen -->
|
||||||
|
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||||
|
<MudText Typo="Typo.h4">Dokumenten-Management</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:240px">
|
||||||
|
@foreach (var b in _buckets)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Refresh">
|
||||||
|
Aktualisieren
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
OnClick="@(() => Nav.NavigateTo("/dms/upload"))"
|
||||||
|
StartIcon="@Icons.Material.Filled.CloudUpload">
|
||||||
|
Upload
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
OnClick="@(() => Nav.NavigateTo("/dms/list"))"
|
||||||
|
StartIcon="@Icons.Material.Filled.List">
|
||||||
|
Zur Liste
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<!-- Kennzahlen -->
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.subtitle1">Gesamt</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@_total.ToString("N0")</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.subtitle1">Eingegangen</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@_eingegangen.ToString("N0")</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.subtitle1">Freigegeben</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@_freigegeben.ToString("N0")</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.subtitle1">Bezahlt</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@_bezahlt.ToString("N0")</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<!-- Zuletzt hinzugefügt + Filter -->
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||||
|
<MudText Typo="Typo.h6">Zuletzt hinzugefügt</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudTextField @bind-Value="_query"
|
||||||
|
Placeholder="Suche (optional)"
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Immediate="true" />
|
||||||
|
<MudTextField @bind-Value="_prefix" Placeholder="Pfad-Prefix (optional, z. B. rechnungen/2026)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Search">
|
||||||
|
Filtern
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="_latest" Dense="true" Hover="true" Class="mt-3">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Datei</MudTh>
|
||||||
|
<MudTh>Pfad</MudTh>
|
||||||
|
<MudTh>Größe</MudTh>
|
||||||
|
<MudTh>Geändert</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.FileName</MudTd>
|
||||||
|
<MudTd>@context.PathPrefix</MudTd>
|
||||||
|
<MudTd>@(context.Size?.ToString("N0"))</MudTd>
|
||||||
|
<MudTd>@context.LastModified</MudTd>
|
||||||
|
<MudTd Align="TableCellAlign.Right">
|
||||||
|
<MudTooltip Text="Details">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Description"
|
||||||
|
OnClick="@(() => Nav.NavigateTo($"/dms/detail/{Uri.EscapeDataString(context.Bucket)}/{EncodeKeyForPath(context.Key)}"))" />
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Download">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||||
|
OnClick="@(() => Download(context))" />
|
||||||
|
</MudTooltip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<string> _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<DocumentListItem> _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));
|
||||||
|
}
|
||||||
159
Components/Pages/DmsDetail.razor
Normal file
159
Components/Pages/DmsDetail.razor
Normal file
@@ -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
|
||||||
|
|
||||||
|
<PageTitle>Dokument</PageTitle>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudStack AlignItems=AlignItems.Center Justify=Justify.Center Class="pa-6">
|
||||||
|
<MudProgressCircular Indeterminate="true" />
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
else if (_doc is null)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudAlert Severity="Severity.Error">
|
||||||
|
Dokument nicht gefunden.
|
||||||
|
</MudAlert>
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="Back" Class="mt-3">Zurück</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<!-- Kopfzeile -->
|
||||||
|
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||||
|
<MudText Typo="Typo.h5">@_doc.FileName</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
|
OnClick="Back">
|
||||||
|
Zurück
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<!-- Vorschau (Platzhalter) -->
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudPaper Class="pa-3" Elevation="1">
|
||||||
|
<MudText Typo="Typo.subtitle1">Vorschau</MudText>
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
PDF‑Vorschau Platzhalter – später PDF.js/Viewer integrieren.
|
||||||
|
</MudAlert>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<!-- Details & Aktionen -->
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudPaper Class="pa-3" Elevation="1">
|
||||||
|
<MudText Typo="Typo.subtitle1">Details</MudText>
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
|
||||||
|
<!-- Nicht-generische Tabelle -->
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feld</th>
|
||||||
|
<th>Wert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Bucket</td><td>@_doc.Bucket</td></tr>
|
||||||
|
<tr><td>Key</td><td>@_doc.Key</td></tr>
|
||||||
|
<tr><td>Dateiname</td><td>@_doc.FileName</td></tr>
|
||||||
|
<tr><td>Pfad</td><td>@_doc.PathPrefix</td></tr>
|
||||||
|
<tr><td>Größe</td><td>@(_doc.Size?.ToString("N0")) Bytes</td></tr>
|
||||||
|
<tr><td>Geändert</td><td>@_doc.LastModified</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="1" Class="mt-3">
|
||||||
|
<MudButton Color="Color.Primary"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
StartIcon="@Icons.Material.Filled.Download"
|
||||||
|
OnClick="Download">
|
||||||
|
Download
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
<MudButton Color="Color.Error"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
StartIcon="@Icons.Material.Filled.Delete"
|
||||||
|
OnClick="Delete">
|
||||||
|
Löschen
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
</MudGrid>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 '<b>{_doc.FileName}</b>' 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Components/Pages/DmsList.razor
Normal file
94
Components/Pages/DmsList.razor
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@page "/dms/list"
|
||||||
|
@using MudBlazor
|
||||||
|
@using Pldpro.Web.UI.Models
|
||||||
|
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudStack Row="true" Spacing="2" AlignItems=AlignItems.Center>
|
||||||
|
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:220px" Required="true">
|
||||||
|
@foreach (var b in _buckets)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_prefix" Placeholder="Pfad-Prefix (optional, z. B. rechnungen/2026)" />
|
||||||
|
<MudTextField @bind-Value="_query" Placeholder="Suche (Datei/Ordner)"
|
||||||
|
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Immediate="true" />
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Search">Suchen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" OnClick="@(() => Nav.NavigateTo("/dms/upload"))"
|
||||||
|
StartIcon="@Icons.Material.Filled.CloudUpload">Upload</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<!-- WICHTIG: T="DocumentListItem" angeben -->
|
||||||
|
<MudTable T="DocumentListItem" Items="_items" Dense="true" Hover="true" Class="mt-3">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Datei</MudTh>
|
||||||
|
<MudTh>Bucket</MudTh>
|
||||||
|
<MudTh>Pfad</MudTh>
|
||||||
|
<MudTh>Größe</MudTh>
|
||||||
|
<MudTh>Geändert</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
|
||||||
|
<!-- @context ist vom Typ DocumentListItem -->
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Datei">@context.FileName</MudTd>
|
||||||
|
<MudTd DataLabel="Bucket">@context.Bucket</MudTd>
|
||||||
|
<MudTd DataLabel="Pfad">@context.PathPrefix</MudTd>
|
||||||
|
<MudTd DataLabel="Größe">@context.Size?.ToString("N0")</MudTd>
|
||||||
|
<MudTd DataLabel="Geändert">@context.LastModified</MudTd>
|
||||||
|
<MudTd Align="TableCellAlign.Right">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
OnClick="@(() => Nav.NavigateTo($"/dms/detail/{Uri.EscapeDataString(context.Bucket)}/{EncodeKeyForPath(context.Key)}"))">
|
||||||
|
Details
|
||||||
|
</MudButton>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager PageSizeOptions="new int[] { 10, 25, 50 }" @bind-PageSize="_pageSize" />
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="1" AlignItems=AlignItems.Center Justify=Justify.Center Class="mt-2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Disabled="@(_page == 0)" OnClick="Prev">Zurück</MudButton>
|
||||||
|
<MudText>Seite @(_page + 1)</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Disabled="@(((_page + 1) * _pageSize) >= _total)" OnClick="Next">Weiter</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<string> _buckets = new();
|
||||||
|
private string? _bucket;
|
||||||
|
private string? _prefix;
|
||||||
|
private string? _query;
|
||||||
|
|
||||||
|
private int _page = 0, _pageSize = 25, _total = 0;
|
||||||
|
private List<DocumentListItem> _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<DocumentListItem>
|
||||||
|
_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));
|
||||||
|
}
|
||||||
94
Components/Pages/DmsUpload.razor
Normal file
94
Components/Pages/DmsUpload.razor
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@page "/dms/upload"
|
||||||
|
@using MudBlazor
|
||||||
|
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>Upload</PageTitle>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<MudText Typo="Typo.h5" GutterBottom="true">Dokument-Upload</MudText>
|
||||||
|
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:260px" Required="true" RequiredError="Bitte Bucket wählen">
|
||||||
|
@foreach (var b in _buckets)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="_path"
|
||||||
|
Placeholder="Pfad (optional, z. B. rechnungen/2026)"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Folder" />
|
||||||
|
|
||||||
|
<InputFile OnChange="OnFilesSelected" multiple />
|
||||||
|
|
||||||
|
|
||||||
|
<MudList T="string" Dense="true">
|
||||||
|
@foreach (var msg in _messages)
|
||||||
|
{
|
||||||
|
<MudListItem>@msg</MudListItem>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.List" OnClick="@(() => Nav.NavigateTo("/dms/list"))">
|
||||||
|
Zur Liste
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Dashboard" OnClick="@(() => Nav.NavigateTo("/dms"))">
|
||||||
|
Dashboard
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<string> _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<string> _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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Components/shared/ConfirmDialog.razor
Normal file
18
Components/shared/ConfirmDialog.razor
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@using MudBlazor
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText Typo="Typo.h6">@Title</MudText>
|
||||||
|
<MudText Class="mt-2">@Message</MudText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="() => MudDialog.Cancel()" Variant="Variant.Outlined" Color="Color.Default">Abbrechen</MudButton>
|
||||||
|
<MudButton OnClick="() => MudDialog.Close(DialogResult.Ok(true))" Variant="Variant.Filled" Color="Color.Error">OK</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||||
|
[Parameter] public string Title { get; set; } = "Bestätigen";
|
||||||
|
[Parameter] public string Message { get; set; } = "Sind Sie sicher?";
|
||||||
|
}
|
||||||
|
``
|
||||||
32
Components/shared/StatusChip.razor
Normal file
32
Components/shared/StatusChip.razor
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@using Pldpro.Web.UI.Models
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<MudChip T="string"
|
||||||
|
Value="@Text"
|
||||||
|
Color="@Color"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Label="true"
|
||||||
|
DisableRipple="true"
|
||||||
|
Clickable="false">
|
||||||
|
@Text
|
||||||
|
</MudChip>
|
||||||
|
|
||||||
|
@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
|
||||||
|
};
|
||||||
|
}
|
||||||
20
Models/UI/DocumentModels.cs
Normal file
20
Models/UI/DocumentModels.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
@@ -17,4 +17,9 @@
|
|||||||
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.2.0" />
|
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.2.0" />
|
||||||
<PackageReference Include="MySqlConnector" Version="2.*" />
|
<PackageReference Include="MySqlConnector" Version="2.*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Components\Shared\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -61,7 +61,7 @@ builder.Services.AddSingleton<IAmazonS3>(sp =>
|
|||||||
|
|
||||||
// Domain-Service
|
// Domain-Service
|
||||||
builder.Services.AddScoped<IStorageService, S3StorageService>();
|
builder.Services.AddScoped<IStorageService, S3StorageService>();
|
||||||
|
builder.Services.AddScoped<Pldpro.Web.UI.Services.IDocumentClient, Pldpro.Web.UI.Services.StorageDocumentClient>();
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
20
Services/UI/IDocumentClient.cs
Normal file
20
Services/UI/IDocumentClient.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
using Pldpro.Web.UI.Models;
|
||||||
|
|
||||||
|
namespace Pldpro.Web.UI.Services;
|
||||||
|
|
||||||
|
public interface IDocumentClient
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<string>> ListBucketsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<(IReadOnlyList<DocumentListItem> Items, int Total)> SearchAsync(
|
||||||
|
string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<DocumentDetail?> 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);
|
||||||
|
}
|
||||||
95
Services/UI/StorageDocumentClient.cs
Normal file
95
Services/UI/StorageDocumentClient.cs
Normal file
@@ -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<IReadOnlyList<string>> ListBucketsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var list = await _http.GetFromJsonAsync<List<BucketVm>>("/api/storage/buckets", ct) ?? new();
|
||||||
|
return list.Select(b => b.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<DocumentListItem> Items, int Total)> SearchAsync(
|
||||||
|
string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var objs = await _http.GetFromJsonAsync<List<ObjectVm>>($"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/objects", ct) ?? new();
|
||||||
|
|
||||||
|
IEnumerable<DocumentListItem> 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<DocumentDetail?> 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user