feat(web-client-tpos): implement shop storage management and enhance revenue charts with membership level improvements

This commit is contained in:
Ho Ngoc Hai
2026-03-05 04:16:49 +07:00
parent c86500214b
commit 14f6ddea77
8 changed files with 312 additions and 19 deletions

View File

@@ -139,16 +139,37 @@
Doanh thu 7 ngày gần nhất
</h3>
<div style="display:flex;gap:8px;">
<button class="admin-tab admin-tab--active" style="padding:6px 12px;font-size:12px;">7 ngày</button>
<button class="admin-tab" style="padding:6px 12px;font-size:12px;">30 ngày</button>
<button class="admin-tab @(_revenuePeriod == "daily" ? "admin-tab--active" : "")" style="padding:6px 12px;font-size:12px;" @onclick="@(() => SwitchRevenuePeriod("daily"))">7 ngày</button>
<button class="admin-tab @(_revenuePeriod == "monthly" ? "admin-tab--active" : "")" style="padding:6px 12px;font-size:12px;" @onclick="@(() => SwitchRevenuePeriod("monthly"))">30 ngày</button>
</div>
</div>
<div class="admin-panel__body" style="display:flex;align-items:center;justify-content:center;min-height:200px;">
<div style="text-align:center;color:var(--admin-text-tertiary);">
<i data-lucide="bar-chart-2" style="width:40px;height:40px;margin-bottom:12px;opacity:0.5;"></i>
<p style="font-size:14px;margin:0;">Chưa có dữ liệu doanh thu</p>
<p style="font-size:12px;margin:4px 0 0;">Dữ liệu sẽ hiển thị khi có đơn hàng</p>
</div>
<div class="admin-panel__body" style="min-height:200px;">
@if (_dailyRevenue.Any())
{
var recent = _revenuePeriod == "monthly" ? _dailyRevenue.TakeLast(30).ToList() : _dailyRevenue.TakeLast(7).ToList();
var maxVal = recent.Max(r => r.Revenue);
<div style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:0 8px;">
@foreach (var r in recent)
{
var pct = maxVal > 0 ? (int)(r.Revenue / maxVal * 100) : 0;
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0;">
<span style="font-size:10px;font-weight:600;color:var(--admin-orange-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;">@FormatVND(r.Revenue)</span>
<div style="width:100%;height:@(Math.Max(pct, 4))%;min-height:4px;background:var(--admin-orange-primary);border-radius:6px 6px 0 0;transition:height 0.3s;"></div>
<span style="font-size:10px;color:var(--admin-text-tertiary);">@r.Period.ToString("dd/MM")</span>
</div>
}
</div>
}
else
{
<div style="display:flex;align-items:center;justify-content:center;height:200px;text-align:center;color:var(--admin-text-tertiary);">
<div>
<i data-lucide="bar-chart-2" style="width:40px;height:40px;margin-bottom:12px;opacity:0.5;"></i>
<p style="font-size:14px;margin:0;">Chưa có dữ liệu doanh thu</p>
<p style="font-size:12px;margin:4px 0 0;">Dữ liệu sẽ hiển thị khi có đơn hàng</p>
</div>
</div>
}
</div>
</div>
@@ -225,6 +246,8 @@
private string _posVertical = "cafe";
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.AdminProductInfo> _products = new();
private List<PosDataService.RevenueReportItem> _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);

View File

@@ -2,6 +2,7 @@
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@inject NavigationManager Nav
@using WebClientTpos.Client.Services
@*
@@ -1158,13 +1159,13 @@
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<button @onclick="ShowComingSoonPromo" style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="gift" style="width:12px;height:12px;"></i> Tặng ưu đãi
</button>
<button style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<button @onclick="ShowComingSoonMessage" style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="message-square" style="width:12px;height:12px;"></i> Gửi tin
</button>
<button style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<button @onclick="GoToOrderHistory" style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="history" style="width:12px;height:12px;"></i> Lịch sử đơn
</button>
<button @onclick="@(() => EditMember(m))" style="padding:6px 14px;border-radius:8px;border:none;background:rgba(59,130,246,0.1);color:#3B82F6;font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
@@ -2043,6 +2044,45 @@
}
break;
case "drive":
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:12px;">
<div style="display:flex;gap:8px;flex:1;"><input type="text" @bind="_storageSearch" @bind:event="oninput" placeholder="Tìm kiếm tệp…" style="flex:1;max-width:320px;padding:8px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" /><button class="admin-btn-primary" @onclick="SearchStorageFiles"><i data-lucide="search" style="width:14px;height:14px;"></i>Tìm</button></div>
<div style="display:flex;gap:8px;"><button class="admin-btn-primary" @onclick="ToggleFolderForm"><i data-lucide="folder-plus" style="width:14px;height:14px;"></i>Thư mục mới</button><label class="admin-btn-primary" style="cursor:pointer;"><i data-lucide="upload" style="width:14px;height:14px;"></i>Upload<InputFile OnChange="HandleDriveUpload" style="display:none;" /></label></div>
</div>
@if (_showFolderForm)
{
<div class="admin-panel" style="margin-bottom:16px;"><div class="admin-panel__body" style="display:flex;gap:8px;align-items:center;"><input type="text" @bind="_newFolderName" placeholder="Tên thư mục" style="flex:1;padding:8px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" /><button class="admin-btn-primary" @onclick="CreateFolder">Tạo</button><button @onclick="HideFolderForm" style="padding:8px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;font-size:13px;">Hủy</button></div></div>
}
@if (_currentFolderId.HasValue)
{
<div style="margin-bottom:12px;"><button @onclick="NavigateToParentFolder" style="padding:6px 12px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);cursor:pointer;font-size:12px;"><i data-lucide="arrow-left" style="width:14px;height:14px;"></i> Quay lại</button></div>
}
@if (_storageFolders.Any())
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px;">
@foreach (var folder in _storageFolders)
{
<div @onclick="@(() => NavigateToFolder(folder.Id))" style="padding:16px;border-radius:12px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);cursor:pointer;display:flex;align-items:center;gap:10px;"><i data-lucide="folder" style="width:24px;height:24px;color:#F59E0B;"></i><div style="flex:1;min-width:0;"><div style="font-size:13px;font-weight:600;">@folder.Name</div><div style="font-size:11px;color:var(--admin-text-tertiary);">@folder.CreatedAt.ToString("dd/MM/yyyy")</div></div><button @onclick="@(() => DeleteFolder(folder.Id))" @onclick:stopPropagation style="padding:4px;border:none;background:transparent;color:var(--admin-text-tertiary);cursor:pointer;"><i data-lucide="trash-2" style="width:14px;height:14px;"></i></button></div>
}
</div>
}
<div class="admin-panel"><div class="admin-panel__header"><h3 class="admin-panel__title">Tệp (@_storageFiles.Count)</h3></div><div class="admin-panel__body" style="padding:0;">
@if (!_storageFiles.Any())
{
<div style="padding:40px;text-align:center;color:var(--admin-text-tertiary);"><i data-lucide="file-x" style="width:40px;height:40px;margin-bottom:8px;opacity:0.3;"></i><div>Chưa có tệp nào.</div></div>
}
else
{
<table class="admin-data-table"><thead><tr><th>Tên tệp</th><th>Loại</th><th>Kích thước</th><th>Ngày upload</th><th>Thao tác</th></tr></thead><tbody>
@foreach (var f in _storageFiles)
{
<tr><td>@f.FileName</td><td style="font-size:12px;">@(f.ContentType ?? "—")</td><td style="font-size:12px;">@FormatFileSize(f.FileSizeBytes)</td><td style="font-size:12px;">@f.UploadedAt.ToString("dd/MM/yyyy HH:mm")</td><td><button @onclick="@(() => DownloadStorageFile(f.Id))" style="padding:4px 8px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);cursor:pointer;font-size:11px;"><i data-lucide="download" style="width:12px;height:12px;"></i></button><button @onclick="@(() => DeleteStorageFile(f.Id))" style="padding:4px 8px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;font-size:11px;"><i data-lucide="trash-2" style="width:12px;height:12px;"></i></button></td></tr>
}
</tbody></table>
}
</div></div>
break;
// ═══ SETTINGS ═══
case "settings":
@* ─── Shop info (read-only) ─── *@
@@ -2539,6 +2579,13 @@
}
</div>
@if (!string.IsNullOrEmpty(_toastMessage))
{
<div style="position:fixed;bottom:24px;right:24px;background:#1a1a2e;color:#fff;padding:12px 20px;border-radius:8px;font-size:13px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.3);border-left:3px solid #ff5c00;">
@_toastMessage
</div>
}
@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<PosDataService.StorageFileInfo> _storageFiles = new();
private List<PosDataService.StorageFolderInfo> _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" };
}

View File

@@ -449,6 +449,28 @@ public class PosDataService
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
=> await GetListFromApiAsync<LevelDefinitionInfo>("api/bff/membership/levels");
public static List<LevelDefinitionInfo> EnrichLevelDefinitions(List<LevelDefinitionInfo> levels, List<MemberInfo> members)
{
if (!levels.Any()) return levels;
var sorted = levels.OrderBy(l => l.LevelNumber).ToList();
var enriched = new List<LevelDefinitionInfo>();
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<MemberInfo> ResolveMemberLevelNames(List<MemberInfo> members, List<LevelDefinitionInfo> 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<List<StorageFileInfo>> 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<List<StorageFileInfo>>($"api/bff/files{qs}", _jsonOptions) ?? new();
}
public async Task<bool> DeleteStorageFileAsync(Guid fileId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/files/{fileId}"); return r.IsSuccessStatusCode; }
public async Task<string?> 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<JsonElement>();
return json.TryGetProperty("url", out var url) ? url.GetString() : null;
}
public async Task<List<StorageFolderInfo>> GetFoldersAsync(Guid? parentId = null)
{
AttachToken();
var qs = parentId.HasValue ? $"?parentId={parentId}" : "";
return await _http.GetFromJsonAsync<List<StorageFolderInfo>>($"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<bool> DeleteFolderAsync(Guid folderId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/folders/{folderId}"); return r.IsSuccessStatusCode; }
public async Task<bool> UploadFileRawAsync(MultipartFormDataContent content)
{
AttachToken();
var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content);
return resp.IsSuccessStatusCode;
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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",

View File

@@ -65,4 +65,59 @@ public class StorageController : ControllerBase
var fileName = response.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? "file";
return File(fileStream, contentType, fileName);
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("files")]
public Task<IActionResult> 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();
}
/// <summary>
/// EN: Delete a file by ID.
/// VI: Xóa file theo ID.
/// </summary>
[HttpDelete("files/{fileId:guid}")]
public Task<IActionResult> DeleteFile(Guid fileId) =>
_storage.DeleteAsync($"/api/v1/files/{fileId}").ProxyAsync();
/// <summary>
/// EN: Get a pre-signed download URL for a file.
/// VI: Lấy URL tải file có chữ ký.
/// </summary>
[HttpGet("files/{fileId:guid}/download-url")]
public Task<IActionResult> GetDownloadUrl(Guid fileId) =>
_storage.GetAsync($"/api/v1/files/{fileId}/download-url").ProxyAsync();
/// <summary>
/// EN: List folders, optionally filtered by parent.
/// VI: Liệt kê thư mục, lọc theo thư mục cha nếu có.
/// </summary>
[HttpGet("folders")]
public Task<IActionResult> GetFolders([FromQuery] Guid? parentId = null)
{
var qs = parentId.HasValue ? $"?parentId={parentId}" : "";
return _storage.GetAsync($"/api/v1/storage/folders{qs}").ProxyAsync();
}
/// <summary>
/// EN: Create a folder.
/// VI: Tạo thư mục.
/// </summary>
[HttpPost("folders")]
public Task<IActionResult> CreateFolder([FromBody] System.Text.Json.JsonElement body) =>
_storage.PostAsJsonAsync("/api/v1/storage/folders", body).ProxyAsync();
/// <summary>
/// EN: Delete a folder by ID.
/// VI: Xóa thư mục theo ID.
/// </summary>
[HttpDelete("folders/{folderId:guid}")]
public Task<IActionResult> DeleteFolder(Guid folderId) =>
_storage.DeleteAsync($"/api/v1/storage/folders/{folderId}").ProxyAsync();
}