From 14f6ddea773674eda60e65bcffb1084cfc59feb4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 5 Mar 2026 04:16:49 +0700 Subject: [PATCH] feat(web-client-tpos): implement shop storage management and enhance revenue charts with membership level improvements --- .../Pages/Admin/Shop/ShopOverview.razor | 51 ++++++-- .../Pages/Admin/Shop/ShopPage.razor | 116 ++++++++++++++++-- .../Services/PosDataService.cs | 74 +++++++++++ .../Services/ShopSidebarConfig.cs | 2 + .../wwwroot/css/admin.css | 31 +++++ .../wwwroot/locales/en-US.json | 1 + .../wwwroot/locales/vi-VN.json | 1 + .../Controllers/StorageController.cs | 55 +++++++++ 8 files changed, 312 insertions(+), 19 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor index 3a149cb7..efec47e0 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor @@ -139,16 +139,37 @@ Doanh thu 7 ngày gần nhất
- - + +
-
-
- -

Chưa có dữ liệu doanh thu

-

Dữ liệu sẽ hiển thị khi có đơn hàng

-
+
+ @if (_dailyRevenue.Any()) + { + var recent = _revenuePeriod == "monthly" ? _dailyRevenue.TakeLast(30).ToList() : _dailyRevenue.TakeLast(7).ToList(); + var maxVal = recent.Max(r => r.Revenue); +
+ @foreach (var r in recent) + { + var pct = maxVal > 0 ? (int)(r.Revenue / maxVal * 100) : 0; +
+ @FormatVND(r.Revenue) +
+ @r.Period.ToString("dd/MM") +
+ } +
+ } + else + { +
+
+ +

Chưa có dữ liệu doanh thu

+

Dữ liệu sẽ hiển thị khi có đơn hàng

+
+
+ }
@@ -225,6 +246,8 @@ private string _posVertical = "cafe"; private List _orders = new(); private List _products = new(); + private List _dailyRevenue = new(); + private string _revenuePeriod = "daily"; // EN: Cascade layout reference to set shop context for sidebar switching. // VI: Cascade layout để set shop context cho sidebar chuyển đổi. @@ -247,8 +270,10 @@ // EN: Load KPI data in parallel / VI: Tải dữ liệu KPI song song var ordersTask = DataService.GetOrdersAsync(id); var productsTask = DataService.GetAllProductsAsync(id); + var revenueTask = DataService.GetRevenueReportAsync("daily", id); _orders = await ordersTask; _products = await productsTask; + try { _dailyRevenue = await revenueTask; } catch { _dailyRevenue = new(); } } } catch (Exception ex) @@ -259,6 +284,16 @@ finally { IsLoading = false; } } + private async Task SwitchRevenuePeriod(string period) + { + _revenuePeriod = period; + if (Guid.TryParse(ShopId, out var id)) + { + try { _dailyRevenue = await DataService.GetRevenueReportAsync(period, id); } catch { } + } + StateHasChanged(); + } + private static string FormatVND(decimal val) => val.ToString("N0") + " ₫"; private static string GetStatusBadgeClass(string? status) => ShopVerticalHelper.GetStatusBadgeClass(status); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index 5cad18cc..ce18229a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -2,6 +2,7 @@ @layout AdminLayout @inherits AdminBase @inject PosDataService DataService +@inject NavigationManager Nav @using WebClientTpos.Client.Services @* @@ -1158,13 +1159,13 @@
- - -
+
+ + @if (_showFolderForm) + { +
+ } + @if (_currentFolderId.HasValue) + { +
+ } + @if (_storageFolders.Any()) + { +
+ @foreach (var folder in _storageFolders) + { +
@folder.Name
@folder.CreatedAt.ToString("dd/MM/yyyy")
+ } +
+ } +

Tệp (@_storageFiles.Count)

+ @if (!_storageFiles.Any()) + { +
Chưa có tệp nào.
+ } + else + { + + @foreach (var f in _storageFiles) + { + + } +
Tên tệpLoạiKích thướcNgày uploadThao tác
@f.FileName@(f.ContentType ?? "—")@FormatFileSize(f.FileSizeBytes)@f.UploadedAt.ToString("dd/MM/yyyy HH:mm")
+ } +
+ break; + // ═══ SETTINGS ═══ case "settings": @* ─── Shop info (read-only) ─── *@ @@ -2539,6 +2579,13 @@ } +@if (!string.IsNullOrEmpty(_toastMessage)) +{ +
+ @_toastMessage +
+} + @code { [Parameter] public string ShopId { get; set; } = ""; [Parameter] public string Section { get; set; } = ""; @@ -2839,8 +2886,10 @@ _staffSchedules = await DataService.GetStaffSchedulesAsync(_shopGuid); break; case "customers": - _members = await DataService.GetMembersAsync(); - _memberLevels = await DataService.GetMembershipLevelsAsync(); + var rawMembers = await DataService.GetMembersAsync(); + var rawLevels = await DataService.GetMembershipLevelsAsync(); + _memberLevels = PosDataService.EnrichLevelDefinitions(rawLevels, rawMembers); + _members = PosDataService.ResolveMemberLevelNames(rawMembers, rawLevels); break; case "tables": case "rooms": @@ -2883,6 +2932,10 @@ if (_shopGuid.HasValue) _recipes = await DataService.GetRecipesAsync(_shopGuid); break; + case "drive": + _storageFolders = await DataService.GetFoldersAsync(_currentFolderId); + _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); + break; } } catch (Exception ex) @@ -2978,6 +3031,7 @@ case "zones": _sectionTitle = "Khu vực"; _sectionIcon = "map-pin"; _sectionDescription = "Quản lý khu vực phục vụ."; break; case "combos": _sectionTitle = "Combo dịch vụ"; _sectionIcon = "layers"; _sectionDescription = "Quản lý combo dịch vụ."; break; case "shifts": _sectionTitle = "Ca làm việc"; _sectionIcon = "clock-4"; _sectionDescription = "Lịch ca làm, phân ca."; break; + case "drive": _sectionTitle = "Lưu trữ"; _sectionIcon = "hard-drive"; _sectionDescription = "Quản lý tệp và thư mục."; break; default: _sectionTitle = Section ?? "Trang"; _sectionIcon = "layout-dashboard"; _sectionDescription = "Trang đang phát triển."; break; } } @@ -3342,14 +3396,14 @@ if (_editingMemberId.HasValue) { var ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null)); - if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _members = await DataService.GetMembersAsync(); } + if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; await RefreshMembersAndLevels(); } } else { var (ok, err) = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry, string.IsNullOrWhiteSpace(_newMemberName) ? null : _newMemberName, string.IsNullOrWhiteSpace(_newMemberPhone) ? null : _newMemberPhone)); - if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _memberFormMessage = null; _members = await DataService.GetMembersAsync(); } + if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _memberFormMessage = null; await RefreshMembersAndLevels(); } else { _memberFormMessage = err ?? "Lỗi tạo khách hàng."; _memberFormSuccess = false; } } } @@ -3364,7 +3418,7 @@ private async Task DeleteMemberItem(Guid memberId) { await DataService.DeleteMemberAsync(memberId); - _members = await DataService.GetMembersAsync(); + await RefreshMembersAndLevels(); } // ═══ LEVEL CRUD ═══ @@ -3384,7 +3438,7 @@ var (ok, err) = await DataService.CreateLevelAsync(req); _levelFormSuccess = ok; _levelFormMessage = ok ? "Đã thêm!" : err ?? "Lỗi."; } - if (_levelFormSuccess) { _showLevelForm = false; _memberLevels = await DataService.GetMembershipLevelsAsync(); } + if (_levelFormSuccess) { _showLevelForm = false; await RefreshMembersAndLevels(); } } private void EditLevel(PosDataService.LevelDefinitionInfo lvl) { @@ -3395,7 +3449,7 @@ private async Task DeleteLevel(Guid id) { await DataService.DeleteLevelAsync(id); - _memberLevels = await DataService.GetMembershipLevelsAsync(); + await RefreshMembersAndLevels(); } // ═══ EXP MANAGEMENT ═══ @@ -3411,7 +3465,7 @@ _expFormMessage = result.LeveledUp ? $"Đã cộng {result.PointsAdded} EXP! Lên level {result.CurrentLevel}!" : $"Đã cộng {result.PointsAdded} EXP! Tổng: {result.TotalExpEarned}"; _memberProgress = await DataService.GetMemberProgressAsync(_expMemberId.Value); _expHistory = await DataService.GetExperienceHistoryAsync(_expMemberId.Value); - _members = await DataService.GetMembersAsync(); + await RefreshMembersAndLevels(); } else { _expFormMessage = "Lỗi khi cộng EXP."; _expFormSuccess = false; } } @@ -3702,4 +3756,44 @@ } catch (Exception ex) { _errorMessage = $"Không thể xóa công thức: {ex.Message}"; } } + + private string? _toastMessage; + private void ShowComingSoonPromo() => ShowComingSoon("Tặng ưu đãi"); + private void ShowComingSoonMessage() => ShowComingSoon("Gửi tin nhắn"); + private void GoToOrderHistory() => Nav.NavigateTo($"/admin/shop/{ShopId}/finance"); + private async void ShowComingSoon(string feature) + { + _toastMessage = $"{feature} — tính năng sắp ra mắt!"; + StateHasChanged(); + await Task.Delay(3000); + _toastMessage = null; + StateHasChanged(); + } + + private async Task RefreshMembersAndLevels() + { + var rm = await DataService.GetMembersAsync(); + var rl = await DataService.GetMembershipLevelsAsync(); + _memberLevels = PosDataService.EnrichLevelDefinitions(rl, rm); + _members = PosDataService.ResolveMemberLevelNames(rm, rl); + } + + private void ToggleFolderForm() => _showFolderForm = !_showFolderForm; + private void HideFolderForm() => _showFolderForm = false; + private List _storageFiles = new(); + private List _storageFolders = new(); + private Guid? _currentFolderId; + private string _newFolderName = ""; + private bool _showFolderForm; + private string _storageSearch = ""; + private async Task SearchStorageFiles() => _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); + private async Task NavigateToFolder(Guid id) { _currentFolderId = id; _storageFolders = await DataService.GetFoldersAsync(id); _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); } + private async Task NavigateToParentFolder() { _currentFolderId = null; _storageFolders = await DataService.GetFoldersAsync(null); _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); } + private async Task CreateFolder() { if (string.IsNullOrWhiteSpace(_newFolderName)) return; await DataService.CreateFolderAsync(new PosDataService.CreateFolderRequest(_newFolderName.Trim(), _currentFolderId)); _newFolderName = ""; _showFolderForm = false; _storageFolders = await DataService.GetFoldersAsync(_currentFolderId); } + private async Task DeleteFolder(Guid id) { await DataService.DeleteFolderAsync(id); _storageFolders = await DataService.GetFoldersAsync(_currentFolderId); } + private async Task DeleteStorageFile(Guid id) { await DataService.DeleteStorageFileAsync(id); _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); } + private async Task DownloadStorageFile(Guid id) { var url = await DataService.GetDownloadUrlAsync(id); if (url != null) Nav.NavigateTo(url, forceLoad: true); } + private async Task HandleDriveUpload(InputFileChangeEventArgs e) { foreach (var f in e.GetMultipleFiles(20)) { using var s = f.OpenReadStream(10_485_760); await DataService.UploadImageAsync(s, f.Name, f.ContentType); } _storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch); } + private static string FormatFileSize(long b) => b < 1024 ? $"{b} B" : b < 1048576 ? $"{b/1024.0:F1} KB" : b < 1073741824 ? $"{b/1048576.0:F1} MB" : $"{b/1073741824.0:F2} GB"; + private static string GetFileIcon(string? ct) => ct switch { string s when s.StartsWith("image/") => "image", string s when s.StartsWith("video/") => "video", string s when s.Contains("pdf") => "file-text", _ => "file" }; } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 93c251d8..3cceb315 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -449,6 +449,28 @@ public class PosDataService public async Task> GetMembershipLevelsAsync() => await GetListFromApiAsync("api/bff/membership/levels"); + public static List EnrichLevelDefinitions(List levels, List members) + { + if (!levels.Any()) return levels; + var sorted = levels.OrderBy(l => l.LevelNumber).ToList(); + var enriched = new List(); + for (int i = 0; i < sorted.Count; i++) + { + var lvl = sorted[i]; + int minExp = lvl.RequiredExp; + int maxExp = (i + 1 < sorted.Count) ? sorted[i + 1].RequiredExp - 1 : int.MaxValue; + int memberCount = members.Count(m => m.CurrentLevel == lvl.LevelNumber); + enriched.Add(lvl with { MinExp = minExp, MaxExp = maxExp == int.MaxValue ? 999999 : maxExp, MemberCount = memberCount }); + } + return enriched; + } + + public static List ResolveMemberLevelNames(List members, List levels) + { + var levelMap = levels.ToDictionary(l => l.LevelNumber, l => l.Name); + return members.Select(m => m with { LevelName = levelMap.GetValueOrDefault(m.CurrentLevel) ?? m.LevelName }).ToList(); + } + // ═══ LEVEL DEFINITION CRUD ═══ public record CreateLevelRequest(int LevelNumber, string Name, int RequiredExp, string? Description, string? BadgeColor); @@ -862,4 +884,56 @@ public class PosDataService var err = await TryExtractError(resp); return (false, err); } + + // ═══ STORAGE / DRIVE ═══ + + public record StorageFileInfo(Guid Id, string FileName, string? ContentType, long FileSizeBytes, string? AccessLevel, DateTime UploadedAt); + public record StorageFolderInfo(Guid Id, Guid? ParentId, string Name, string? Path, DateTime CreatedAt); + public record CreateFolderRequest(string Name, Guid? ParentId); + + public async Task> GetStorageFilesAsync(int skip = 0, int take = 50, string? search = null) + { + AttachToken(); + var qs = $"?skip={skip}&take={take}"; + if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; + return await _http.GetFromJsonAsync>($"api/bff/files{qs}", _jsonOptions) ?? new(); + } + + public async Task DeleteStorageFileAsync(Guid fileId) + { AttachToken(); var r = await _http.DeleteAsync($"api/bff/files/{fileId}"); return r.IsSuccessStatusCode; } + + public async Task GetDownloadUrlAsync(Guid fileId) + { + AttachToken(); + var resp = await _http.GetAsync($"api/bff/files/{fileId}/download-url"); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadFromJsonAsync(); + return json.TryGetProperty("url", out var url) ? url.GetString() : null; + } + + public async Task> GetFoldersAsync(Guid? parentId = null) + { + AttachToken(); + var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; + return await _http.GetFromJsonAsync>($"api/bff/folders{qs}", _jsonOptions) ?? new(); + } + + public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/folders", req, _writeOptions); + if (resp.IsSuccessStatusCode) return (true, null); + var err = await TryExtractError(resp); + return (false, err); + } + + public async Task DeleteFolderAsync(Guid folderId) + { AttachToken(); var r = await _http.DeleteAsync($"api/bff/folders/{folderId}"); return r.IsSuccessStatusCode; } + + public async Task UploadFileRawAsync(MultipartFormDataContent content) + { + AttachToken(); + var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content); + return resp.IsSuccessStatusCode; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs index c6d195e1..4a0fed11 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs @@ -39,6 +39,7 @@ public static class ShopSidebarConfig new("Shop_Menu_Customers", "heart", "customers"), new("Shop_Menu_Promotions", "tag", "promotions"), new("Shop_Menu_Reports", "bar-chart-2", "reports"), + new("Shop_Menu_Drive", "hard-drive", "drive"), new("Shop_Menu_Settings", "settings", "settings"), }; @@ -116,6 +117,7 @@ public static class ShopSidebarConfig items.Add(new("Shop_Menu_Customers", "heart", "customers")); items.Add(new("Shop_Menu_Promotions", "tag", "promotions")); items.Add(new("Shop_Menu_Reports", "bar-chart-2", "reports")); + items.Add(new("Shop_Menu_Drive", "hard-drive", "drive")); items.Add(new("Shop_Menu_Settings", "settings", "settings")); } else diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/admin.css b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/admin.css index c4e66de4..3b3ed6ca 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/admin.css +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/admin.css @@ -596,6 +596,35 @@ a.admin-store-card:hover { color: var(--admin-text-tertiary); } +/* EN: Stat card (compact KPI) / VI: Thẻ thống kê (KPI gọn) */ +.admin-stat-card { + background: var(--admin-bg-elevated); + border-radius: var(--admin-radius-xl, 16px); + padding: 16px 20px; + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--admin-border-subtle); + transition: border-color 0.2s, box-shadow 0.2s; +} +.admin-stat-card:hover { + border-color: rgba(255, 92, 0, 0.25); + box-shadow: 0 2px 12px rgba(255, 92, 0, 0.06); +} +.admin-stat-card__icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.admin-stat-card__icon i { width: 20px; height: 20px; } +.admin-stat-card__content { display: flex; flex-direction: column; gap: 2px; } +.admin-stat-card__value { font-size: 20px; font-weight: 700; color: var(--admin-text-primary); line-height: 1.2; } +.admin-stat-card__label { font-size: 12px; color: var(--admin-text-tertiary); font-weight: 500; } + /* EN: Panel/card with header / VI: Panel/thẻ có header */ .admin-panel { background-color: var(--admin-bg-elevated); @@ -1037,6 +1066,8 @@ a.admin-store-card:hover { .admin-stat-card { min-width: calc(50% - 8px); } + .admin-stat-card__value { font-size: 18px; } + .admin-stat-card__icon { width: 36px; height: 36px; } /* EN: Tables scroll horizontally / VI: Bảng scroll ngang */ .admin-table { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json index 8efe0049..dd8d5ebc 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json @@ -389,6 +389,7 @@ "Shop_Menu_Customers": "Customers", "Shop_Menu_Promotions": "Promotions", "Shop_Menu_Reports": "Reports", + "Shop_Menu_Drive": "Storage", "Shop_Menu_Settings": "Settings", "Vertical_Cafe": "Café", "Vertical_Restaurant": "Restaurant / Bar", diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json index 5b8e8bac..ed42fc94 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json @@ -389,6 +389,7 @@ "Shop_Menu_Customers": "Khách hàng", "Shop_Menu_Promotions": "Khuyến mãi", "Shop_Menu_Reports": "Báo cáo", + "Shop_Menu_Drive": "Lưu trữ", "Shop_Menu_Settings": "Thiết lập", "Vertical_Cafe": "Café", "Vertical_Restaurant": "Nhà hàng / Bar", diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs index 82ea0733..8a4706d2 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs @@ -65,4 +65,59 @@ public class StorageController : ControllerBase var fileName = response.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? "file"; return File(fileStream, contentType, fileName); } + + /// + /// EN: List files with pagination and optional search. + /// VI: Liệt kê files có phân trang và tìm kiếm tùy chọn. + /// + [HttpGet("files")] + public Task GetFiles([FromQuery] int skip = 0, [FromQuery] int take = 50, [FromQuery] string? search = null) + { + var qs = $"?skip={skip}&take={take}"; + if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; + return _storage.GetAsync($"/api/v1/files{qs}").ProxyAsync(); + } + + /// + /// EN: Delete a file by ID. + /// VI: Xóa file theo ID. + /// + [HttpDelete("files/{fileId:guid}")] + public Task DeleteFile(Guid fileId) => + _storage.DeleteAsync($"/api/v1/files/{fileId}").ProxyAsync(); + + /// + /// EN: Get a pre-signed download URL for a file. + /// VI: Lấy URL tải file có chữ ký. + /// + [HttpGet("files/{fileId:guid}/download-url")] + public Task GetDownloadUrl(Guid fileId) => + _storage.GetAsync($"/api/v1/files/{fileId}/download-url").ProxyAsync(); + + /// + /// EN: List folders, optionally filtered by parent. + /// VI: Liệt kê thư mục, lọc theo thư mục cha nếu có. + /// + [HttpGet("folders")] + public Task GetFolders([FromQuery] Guid? parentId = null) + { + var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; + return _storage.GetAsync($"/api/v1/storage/folders{qs}").ProxyAsync(); + } + + /// + /// EN: Create a folder. + /// VI: Tạo thư mục. + /// + [HttpPost("folders")] + public Task CreateFolder([FromBody] System.Text.Json.JsonElement body) => + _storage.PostAsJsonAsync("/api/v1/storage/folders", body).ProxyAsync(); + + /// + /// EN: Delete a folder by ID. + /// VI: Xóa thư mục theo ID. + /// + [HttpDelete("folders/{folderId:guid}")] + public Task DeleteFolder(Guid folderId) => + _storage.DeleteAsync($"/api/v1/storage/folders/{folderId}").ProxyAsync(); }