refactor(web-client-tpos): extract shop admin sections into dedicated Blazor components and centralize helpers

This commit is contained in:
Ho Ngoc Hai
2026-03-05 07:16:16 +07:00
parent 802c03995a
commit 7102b89ef1
17 changed files with 3634 additions and 3453 deletions

View File

@@ -0,0 +1,136 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:flex;justify-content:flex-end;margin-bottom:12px;">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showApptForm = !_showApptForm; _apptFormMessage = null; _newApptStart = DateTime.Today.AddHours(9); _newApptEnd = DateTime.Today.AddHours(10); }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm lịch hẹn
</button>
</div>
@if (_showApptForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(236,72,153,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm lịch hẹn mới</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian bắt đầu *</label><input type="datetime-local" @bind="_newApptStart" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian kết thúc *</label><input type="datetime-local" @bind="_newApptEnd" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="AddAppointment" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
<button @onclick='() => _showApptForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_apptFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_apptFormSuccess ? "#22C55E" : "#EF4444");">@_apptFormMessage</div> }
</div>
</div>
}
@{
var calWeekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek + 1 + _calendarWeekOffset * 7);
var calWeekEnd = calWeekStart.AddDays(7);
var weekAppts = _appointments.Where(a => a.StartTime >= calWeekStart && a.StartTime < calWeekEnd).ToList();
}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="calendar" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_appointments.Count</span><span class="admin-stat-card__label">Tổng lịch hẹn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_appointments.Count(a => a.Status == "Confirmed")</span><span class="admin-stat-card__label">Đã xác nhận</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="clock" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_appointments.Count(a => a.Status == "Pending")</span><span class="admin-stat-card__label">Chờ xác nhận</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="calendar-range" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@weekAppts.Count</span><span class="admin-stat-card__label">Tuần này</span></div></div>
</div>
</div>
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:14px;font-weight:700;">Lịch hẹn tuần @calWeekStart.ToString("dd/MM") — @calWeekEnd.AddDays(-1).ToString("dd/MM")</h3>
<div style="display:flex;gap:4px;">
<button @onclick='() => { _calendarWeekOffset--; StateHasChanged(); }' style="padding:6px 12px;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;">← Trước</button>
<button @onclick='() => { _calendarWeekOffset = 0; StateHasChanged(); }' style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-orange-primary);background:rgba(255,92,0,0.1);color:var(--admin-orange-primary);font-size:12px;font-weight:600;cursor:pointer;">Hôm nay</button>
<button @onclick='() => { _calendarWeekOffset++; StateHasChanged(); }' style="padding:6px 12px;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;">Sau →</button>
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
<div style="display:grid;grid-template-columns:repeat(7,1fr);min-height:300px;">
@for (int d = 0; d < 7; d++)
{
var day = calWeekStart.AddDays(d);
var dayAppts = weekAppts.Where(a => a.StartTime.Date == day.Date).OrderBy(a => a.StartTime).ToList();
var isToday = day.Date == DateTime.Today;
<div style="border-right:1px solid var(--admin-border-subtle);padding:8px;@(d == 6 ? "border-right:none;" : "")">
<div style="text-align:center;padding:6px 0;margin-bottom:8px;border-radius:8px;@(isToday ? "background:rgba(255,92,0,0.15);" : "")">
<div style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);">@DayLabel((int)day.DayOfWeek)</div>
<div style="font-size:16px;font-weight:700;@(isToday ? "color:var(--admin-orange-primary);" : "")">@day.Day</div>
</div>
@foreach (var appt in dayAppts)
{
var apptColor = appt.Status switch { "Confirmed" => "#22C55E", "Pending" => "#F59E0B", "Completed" => "#3B82F6", _ => "#6B6B6F" };
<div style="background:@($"{apptColor}15");border-left:3px solid @apptColor;border-radius:6px;padding:6px 8px;margin-bottom:4px;font-size:11px;">
<div style="font-weight:600;">@appt.StartTime.ToString("HH:mm")-@appt.EndTime.ToString("HH:mm")</div>
<div style="color:var(--admin-text-tertiary);margin-top:2px;">@(appt.ResourceName ?? "—")</div>
@if (appt.Status != "Cancelled" && appt.Status != "Completed")
{
<button @onclick='() => CancelAppt(appt.Id)' style="margin-top:4px;padding:2px 6px;border-radius:4px;border:none;background:rgba(239,68,68,0.15);color:#EF4444;font-size:10px;cursor:pointer;">Hủy</button>
}
</div>
}
@if (!dayAppts.Any())
{
<div style="text-align:center;padding:20px 0;font-size:11px;color:var(--admin-text-quaternary, #555);">—</div>
}
</div>
}
</div>
</div>
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
// Appointments state
private List<PosDataService.AppointmentInfo> _appointments = new();
// Appointments form state
private bool _showApptForm;
private DateTime _newApptStart = DateTime.Today.AddHours(9);
private DateTime _newApptEnd = DateTime.Today.AddHours(10);
private string? _apptFormMessage;
private bool _apptFormSuccess;
private int _calendarWeekOffset;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_appointments = await DataService.GetAppointmentsAsync(ShopId);
}
private async Task AddAppointment()
{
_apptFormMessage = null;
if (ShopId == Guid.Empty)
{
_apptFormMessage = "Thiếu thông tin cửa hàng."; _apptFormSuccess = false; return;
}
try
{
await DataService.CreateAppointmentAsync(new PosDataService.CreateAppointmentRequest(
ShopId, null, null, null, null, _newApptStart, _newApptEnd));
_apptFormMessage = "Đã thêm lịch hẹn thành công!"; _apptFormSuccess = true;
_showApptForm = false;
_appointments = await DataService.GetAppointmentsAsync(ShopId);
}
catch (Exception ex) { _apptFormMessage = $"Lỗi: {ex.Message}"; _apptFormSuccess = false; }
}
private async Task CancelAppt(Guid apptId)
{
try
{
await DataService.CancelAppointmentAsync(apptId);
if (ShopId != Guid.Empty) _appointments = await DataService.GetAppointmentsAsync(ShopId);
}
catch (Exception ex) { _errorMessage = $"Không thể hủy lịch hẹn: {ex.Message}"; }
}
private static string DayLabel(int dow) => dow switch
{
0 => "CN", 1 => "T2", 2 => "T3", 3 => "T4",
4 => "T5", 5 => "T6", 6 => "T7", _ => $"#{dow}"
};
}

View File

@@ -0,0 +1,491 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@inject NavigationManager Nav
<div>
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
Dữ liệu khách hàng chung cho tất cả cửa hàng trong thương hiệu
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;border-bottom:2px solid var(--admin-border-subtle);padding-bottom:8px;">
@foreach (var (tab, label) in new[] { ("members","Khách hàng"), ("levels","Cấp bậc"), ("exp","Điểm EXP") })
{
<button @onclick="@(() => _customerSubTab = tab)"
style="padding:8px 16px;border-radius:8px 8px 0 0;border:none;font-size:13px;font-weight:600;cursor:pointer;background:@(_customerSubTab == tab ? "var(--admin-orange-primary)" : "transparent");color:@(_customerSubTab == tab ? "#FFF" : "var(--admin-text-secondary)");">
@label
</button>
}
</div>
@if (_customerSubTab == "levels")
{
@* ─── Level CRUD ─── *@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_memberLevels.Count cấp bậc</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showLevelForm = !_showLevelForm; _editingLevelId = null; _newLevelNumber = _memberLevels.Count + 1; _newLevelName = ""; _newLevelRequiredExp = 0; _newLevelDescription = ""; _newLevelBadgeColor = "#CD7F32"; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm cấp bậc
</button>
</div>
@if (_showLevelForm)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingLevelId.HasValue ? "Sửa cấp bậc" : "Thêm cấp bậc")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số level *</label><input type="number" @bind="_newLevelNumber" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên cấp bậc *</label><input type="text" @bind="_newLevelName" placeholder="Bronze" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">EXP cần *</label><input type="number" @bind="_newLevelRequiredExp" min="0" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newLevelDescription" placeholder="Mô tả quyền lợi..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Màu badge</label><input type="color" @bind="_newLevelBadgeColor" style="width:100%;height:36px;padding:2px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);cursor:pointer;" /></div>
</div>
@if (_levelFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_levelFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_levelFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_levelFormMessage</div>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="SaveLevel">Lưu</button>
<button style="padding:6px 16px;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;" @onclick="() => _showLevelForm = false">Hủy</button>
</div>
</div>
</div>
}
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Level</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP cần</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mô tả</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Màu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thành viên</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var lvl in _memberLevels.OrderBy(l => l.Level))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@lvl.Level</td>
<td style="padding:12px 16px;font-weight:600;">@lvl.Name</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;">@lvl.RequiredExp.ToString("N0")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(lvl.Description ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:@(lvl.BadgeColor ?? "#CCC");"></span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@lvl.MemberCount</td>
<td style="padding:12px 16px;text-align:center;">
<button @onclick="@(() => EditLevel(lvl))" style="padding:4px 8px;border-radius:6px;border:none;background:rgba(59,130,246,0.1);color:#3B82F6;font-size:11px;cursor:pointer;margin-right:4px;">Sửa</button>
<button @onclick="@(() => DeleteLevel(lvl.Id))" style="padding:4px 8px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;">Xóa</button>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
else if (_customerSubTab == "exp")
{
@* ─── EXP Management ─── *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cộng điểm EXP</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thành viên</label>
<select @bind="_expMemberId" @bind:after="LoadMemberProgress" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="">-- Chọn --</option>
@foreach (var m in _members)
{
<option value="@m.Id">@(m.DisplayName ?? m.Id.ToString()[..8]) (@m.TotalExpEarned EXP)</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số điểm</label><input type="number" @bind="_expPoints" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nguồn</label>
<select @bind="_expSourceId" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="1">Mua hàng</option><option value="2">Giới thiệu</option><option value="3">Hoạt động</option>
<option value="4">Khuyến mãi</option><option value="5">Đánh giá</option><option value="6">Check-in</option><option value="7">Admin</option>
</select>
</div>
</div>
@if (_expFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_expFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_expFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_expFormMessage</div>
}
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;margin-top:12px;" @onclick="AddExpToMember">Cộng EXP</button>
</div>
</div>
@if (_memberProgress != null)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Tiến trình: @(_memberProgress.CurrentLevelName ?? "Level") @_memberProgress.CurrentLevel</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:16px;margin-bottom:16px;">
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">EXP hiện tại</span><div style="font-weight:700;font-size:18px;color:var(--admin-orange-primary);">@_memberProgress.CurrentExp.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Tổng EXP</span><div style="font-weight:700;font-size:18px;">@_memberProgress.TotalExpEarned.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Cần thêm</span><div style="font-weight:700;font-size:18px;">@_memberProgress.ExpToNextLevel.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Level kế</span><div style="font-weight:700;font-size:18px;">@(_memberProgress.NextLevelName ?? "Max")</div></div>
</div>
<div style="background:var(--admin-border-subtle);border-radius:8px;height:12px;overflow:hidden;">
<div style="background:var(--admin-orange-primary);height:100%;border-radius:8px;width:@(_memberProgress.ProgressPercent)%;transition:width 0.3s;"></div>
</div>
<div style="text-align:right;font-size:12px;color:var(--admin-text-tertiary);margin-top:4px;">@_memberProgress.ProgressPercent%</div>
</div>
</div>
}
@if (_expHistory.Any())
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch sử EXP</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Điểm</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguồn</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tham chiếu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Level</th>
</tr></thead><tbody>
@foreach (var tx in _expHistory)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:13px;">@tx.CreatedAt.ToString("dd/MM/yyyy HH:mm")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(tx.Points > 0 ? "#22C55E" : "#EF4444");">@(tx.Points > 0 ? "+" : "")@tx.Points</td>
<td style="padding:12px 16px;font-size:13px;">@(tx.Source ?? "—")</td>
<td style="padding:12px 16px;font-size:12px;color:var(--admin-text-tertiary);font-family:monospace;">@(tx.ReferenceId ?? "—")</td>
<td style="padding:12px 16px;text-align:right;">@tx.LevelAtTime</td>
</tr>
}
</tbody></table>
</div>
</div>
}
}
else
{
@* ─── Members list ─── *@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_members.Count khách hàng</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div style="position:relative;">
<i data-lucide="search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
<input type="text" placeholder="Tìm theo ID..." @bind="_customerSearch" @bind:event="oninput"
style="padding:8px 12px 8px 36px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);width:200px;" />
</div>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showMemberForm = !_showMemberForm; _editingMemberId = null; _newMemberGender = ""; _newMemberCountry = "VN"; _newMemberName = ""; _newMemberPhone = ""; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm khách hàng
</button>
</div>
</div>
@if (_showMemberForm)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingMemberId.HasValue ? "Sửa khách hàng" : "Thêm khách hàng")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên khách hàng *</label><input type="text" @bind="_newMemberName" placeholder="Nguyễn Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số điện thoại</label><input type="tel" @bind="_newMemberPhone" placeholder="0901234567" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giới tính</label>
<select @bind="_newMemberGender" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="">-- Chọn --</option>
<option value="male">Nam</option>
<option value="female">Nữ</option>
<option value="other">Khác</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mã quốc gia</label><input type="text" @bind="_newMemberCountry" maxlength="2" placeholder="VN" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
</div>
@if (_memberFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_memberFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_memberFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_memberFormMessage</div>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="SaveMember">Lưu</button>
<button style="padding:6px 16px;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;" @onclick='() => { _showMemberForm = false; _memberFormMessage = null; }'>Hủy</button>
</div>
</div>
</div>
}
var filteredMembers = string.IsNullOrWhiteSpace(_customerSearch)
? _members
: _members.Where(m => m.Id.ToString().Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.DisplayName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.Phone ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|| (m.LevelName ?? "").Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)).ToList();
@if (!filteredMembers.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(239,68,68,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="heart" style="width:36px;height:36px;color:#EF4444;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có khách hàng</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Khách hàng sẽ hiển thị khi có giao dịch</p>
<a href="/pos/@ShopId/@PosVertical" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="monitor" style="width:16px;height:16px;"></i>
Mở POS bán hàng
</a>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="users" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@filteredMembers.Count</span><span class="admin-stat-card__label">Tổng khách hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="crown" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_memberLevels.Count</span><span class="admin-stat-card__label">Cấp bậc</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="star" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@(filteredMembers.Any() ? filteredMembers.Max(m => m.TotalExpEarned).ToString("N0") : "0")</span><span class="admin-stat-card__label">EXP cao nhất</span></div></div>
</div>
@if (_memberLevels.Any())
{
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cấp bậc thành viên</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Level</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP cần</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thành viên</th>
</tr></thead><tbody>
@foreach (var lvl in _memberLevels.OrderBy(l => l.Level))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@lvl.Level</td>
<td style="padding:12px 16px;font-weight:600;">@lvl.Name</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@lvl.MinExp.ToString("N0") — @lvl.MaxExp.ToString("N0")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@lvl.MemberCount</td>
</tr>
}
</tbody></table>
</div>
</div>
}
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách khách hàng</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">SĐT</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Cấp bậc</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tham gia</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var m in filteredMembers)
{
var isExpanded = _selectedCustomerId == m.Id;
<tr style="border-top:1px solid var(--admin-border-subtle);cursor:pointer;transition:background 0.15s;@(isExpanded ? "background:rgba(255,92,0,0.05);" : "")" @onclick='() => { _selectedCustomerId = isExpanded ? null : m.Id; StateHasChanged(); }'>
<td style="padding:12px 16px;font-weight:600;">@(m.DisplayName ?? m.Id.ToString()[..8])</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(m.Phone ?? "—")</td>
<td style="padding:12px 16px;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(m.LevelName ?? "—")</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@m.TotalExpEarned.ToString("N0")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@m.CreatedAt.ToString("dd/MM/yyyy")</td>
<td style="padding:12px 16px;text-align:center;"><i data-lucide="@(isExpanded ? "chevron-up" : "chevron-down")" style="width:14px;height:14px;color:var(--admin-text-tertiary);"></i></td>
</tr>
@if (isExpanded)
{
<tr><td colspan="6" style="padding:0;">
<div style="padding:16px 20px;background:rgba(255,92,0,0.03);border-top:2px solid var(--admin-orange-primary);">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
<div>
<label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">Mã KH</label>
<div style="font-family:monospace;font-size:12px;">@m.Id.ToString()</div>
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">Cấp bậc hiện tại</label>
<div style="font-weight:600;">@(m.LevelName ?? "Chưa xếp hạng") - @m.TotalExpEarned.ToString("N0") EXP</div>
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">Tham gia từ</label>
<div style="font-size:13px;">@m.CreatedAt.ToString("dd/MM/yyyy HH:mm")</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button @onclick="ShowComingSoonPromo" @onclick:stopPropagation 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 @onclick="ShowComingSoonMessage" @onclick:stopPropagation 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 @onclick="GoToOrderHistory" @onclick:stopPropagation 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))" @onclick:stopPropagation 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;">
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Sửa
</button>
<button @onclick="@(() => DeleteMemberItem(m.Id))" @onclick:stopPropagation style="padding:6px 14px;border-radius:8px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i> Xóa
</button>
</div>
</div>
</td></tr>
}
}
</tbody></table>
</div>
</div>
}
}
</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 Guid ShopGuid { get; set; }
[Parameter] public string ShopId { get; set; } = "";
[Parameter] public string PosVertical { get; set; } = "cafe";
// Members data
private List<PosDataService.MemberInfo> _members = new();
private List<PosDataService.LevelDefinitionInfo> _memberLevels = new();
// Customer sub-tab
private string _customerSubTab = "members";
private string _customerSearch = "";
private Guid? _selectedCustomerId;
// Member form
private bool _showMemberForm;
private Guid? _editingMemberId;
private string _newMemberGender = "";
private string _newMemberCountry = "VN";
private string _newMemberName = "";
private string _newMemberPhone = "";
private string? _memberFormMessage;
private bool _memberFormSuccess;
// Level form
private bool _showLevelForm;
private Guid? _editingLevelId;
private int _newLevelNumber;
private string _newLevelName = "";
private int _newLevelRequiredExp;
private string _newLevelDescription = "";
private string _newLevelBadgeColor = "#CD7F32";
private string? _levelFormMessage;
private bool _levelFormSuccess;
// EXP management
private Guid? _expMemberId;
private int _expPoints = 100;
private int _expSourceId = 7;
private string? _expFormMessage;
private bool _expFormSuccess;
private PosDataService.MemberProgressInfo? _memberProgress;
private List<PosDataService.ExpTransactionInfo> _expHistory = new();
// Toast
private string? _toastMessage;
protected override async Task OnInitializedAsync()
{
await RefreshMembersAndLevels();
}
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 async Task SaveMember()
{
_memberFormMessage = null;
if (_editingMemberId.HasValue)
{
var ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
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; await RefreshMembersAndLevels(); }
else { _memberFormMessage = err ?? "Lỗi tạo khách hàng."; _memberFormSuccess = false; }
}
}
private void EditMember(PosDataService.MemberInfo m)
{
_editingMemberId = m.Id; _newMemberGender = m.Gender ?? ""; _newMemberCountry = m.CountryCode ?? "VN";
_newMemberName = m.DisplayName ?? ""; _newMemberPhone = m.Phone ?? "";
_showMemberForm = true;
}
private async Task DeleteMemberItem(Guid memberId)
{
await DataService.DeleteMemberAsync(memberId);
await RefreshMembersAndLevels();
}
private async Task SaveLevel()
{
_levelFormMessage = null;
if (string.IsNullOrWhiteSpace(_newLevelName) || _newLevelNumber <= 0)
{ _levelFormMessage = "Nhập tên và số level."; _levelFormSuccess = false; return; }
var req = new PosDataService.CreateLevelRequest(_newLevelNumber, _newLevelName, _newLevelRequiredExp, _newLevelDescription, _newLevelBadgeColor);
if (_editingLevelId.HasValue)
{
var (ok, err) = await DataService.UpdateLevelAsync(_editingLevelId.Value, req);
_levelFormSuccess = ok; _levelFormMessage = ok ? "Đã cập nhật!" : err ?? "Lỗi.";
}
else
{
var (ok, err) = await DataService.CreateLevelAsync(req);
_levelFormSuccess = ok; _levelFormMessage = ok ? "Đã thêm!" : err ?? "Lỗi.";
}
if (_levelFormSuccess) { _showLevelForm = false; await RefreshMembersAndLevels(); }
}
private void EditLevel(PosDataService.LevelDefinitionInfo lvl)
{
_editingLevelId = lvl.Id; _newLevelNumber = lvl.LevelNumber; _newLevelName = lvl.Name;
_newLevelRequiredExp = lvl.RequiredExp; _newLevelDescription = lvl.Description ?? "";
_newLevelBadgeColor = lvl.BadgeColor ?? "#CD7F32"; _showLevelForm = true;
}
private async Task DeleteLevel(Guid id)
{
await DataService.DeleteLevelAsync(id);
await RefreshMembersAndLevels();
}
private async Task AddExpToMember()
{
_expFormMessage = null;
if (!_expMemberId.HasValue || _expPoints <= 0)
{ _expFormMessage = "Chọn thành viên và nhập số điểm > 0."; _expFormSuccess = false; return; }
var result = await DataService.AddExperienceAsync(_expMemberId.Value, new PosDataService.AddExpRequest(_expPoints, _expSourceId, null));
if (result != null)
{
_expFormSuccess = true;
_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);
await RefreshMembersAndLevels();
}
else { _expFormMessage = "Lỗi khi cộng EXP."; _expFormSuccess = false; }
}
private async Task LoadMemberProgress()
{
if (_expMemberId.HasValue)
{
_memberProgress = await DataService.GetMemberProgressAsync(_expMemberId.Value);
_expHistory = await DataService.GetExperienceHistoryAsync(_expMemberId.Value);
}
else { _memberProgress = null; _expHistory = new(); }
}
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();
}
}

View File

@@ -0,0 +1,114 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@inject NavigationManager Nav
<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;">@ShopHelpers.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>
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.StorageFileInfo> _storageFiles = new();
private List<PosDataService.StorageFolderInfo> _storageFolders = new();
private Guid? _currentFolderId;
private string _newFolderName = "";
private bool _showFolderForm;
private string _storageSearch = "";
protected override async Task OnInitializedAsync()
{
_storageFolders = await DataService.GetFoldersAsync(_currentFolderId);
_storageFiles = await DataService.GetStorageFilesAsync(search: _storageSearch);
}
private void ToggleFolderForm() => _showFolderForm = !_showFolderForm;
private void HideFolderForm() => _showFolderForm = false;
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);
}
}

View File

@@ -0,0 +1,180 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
Đơn hàng theo cửa hàng · Ví tiền chung cho tài khoản
</div>
@{
var finOrders = _financePeriod switch {
"7d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-7)).ToList(),
"30d" => _orders.Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30)).ToList(),
_ => _orders };
}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">Tài chính</h3>
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
@foreach (var (label, val) in new[] { ("7 ngày", "7d"), ("30 ngày", "30d"), ("Tất cả", "all") })
{
<button @onclick="@(() => { _financePeriod = val; StateHasChanged(); })"
style="padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;
background:@(_financePeriod == val ? "var(--admin-orange-primary)" : "transparent");
color:@(_financePeriod == val ? "#FFF" : "var(--admin-text-tertiary)");">
@label
</button>
}
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="trending-up" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(finOrders.Sum(o => o.TotalAmount))</span><span class="admin-stat-card__label">Tổng doanh thu</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="receipt" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@finOrders.Count</span><span class="admin-stat-card__label">Đơn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="calculator" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(finOrders.Any() ? finOrders.Average(o => o.TotalAmount) : 0)</span><span class="admin-stat-card__label">TB/đơn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="wallet" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(_wallets.Sum(w => w.Balance))</span><span class="admin-stat-card__label">Số dư ví</span></div></div>
</div>
@if (_walletTxns.Any())
{
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Giao dịch ví gần đây</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mô tả</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
</tr></thead><tbody>
@foreach (var t in _walletTxns.Take(15))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;font-size:13px;">@(t.Description ?? t.ItemName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(t.Amount >= 0 ? "#22C55E" : "#EF4444");">@(t.Amount >= 0 ? "+" : "")@ShopHelpers.FormatVND(t.Amount)</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@t.CreatedAt.ToString("dd/MM HH:mm")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@if (_orders.Any())
{
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Đơn hàng gần đây</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">ID</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
<th style="padding:12px 16px;width:32px;"></th>
</tr></thead><tbody>
@foreach (var o in _orders.Take(20))
{
var isOrderExpanded = _selectedOrderId == o.Id;
<tr style="border-top:1px solid var(--admin-border-subtle);cursor:pointer;@(isOrderExpanded ? "background:rgba(255,92,0,0.05);" : "")" @onclick="@(() => ViewOrderDetail(o.Id))">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;color:var(--admin-text-tertiary);">@o.Id.ToString()[..8]</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@ShopHelpers.FormatVND(o.TotalAmount)</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(o.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM HH:mm")</td>
<td style="padding:12px 16px;text-align:center;"><i data-lucide="@(isOrderExpanded ? "chevron-up" : "chevron-down")" style="width:14px;height:14px;color:var(--admin-text-tertiary);"></i></td>
</tr>
@if (isOrderExpanded && _orderDetail != null)
{
<tr><td colspan="5" style="padding:0;">
<div style="padding:16px 20px;background:rgba(255,92,0,0.03);border-top:2px solid var(--admin-orange-primary);">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:12px;">
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">💳 Thanh toán</label><div style="font-size:13px;">@(_orderDetail.Order?.PaymentMethod ?? "—")</div></div>
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">📝 Ghi chú</label><div style="font-size:13px;">@(_orderDetail.Order?.Notes ?? "—")</div></div>
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">🕐 Thời gian</label><div style="font-size:13px;">@(_orderDetail.Order?.CreatedAt.ToString("dd/MM/yyyy HH:mm") ?? "—")</div></div>
</div>
@if (_orderDetail.Items?.Any() == true)
{
<table style="width:100%;border-collapse:collapse;font-size:13px;"><thead><tr style="color:var(--admin-text-tertiary);font-size:11px;">
<th style="text-align:left;padding:4px 8px;">Sản phẩm</th><th style="text-align:center;padding:4px 8px;">SL</th><th style="text-align:right;padding:4px 8px;">Đơn giá</th><th style="text-align:right;padding:4px 8px;">Thành tiền</th>
</tr></thead><tbody>
@foreach (var item in _orderDetail.Items)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:6px 8px;font-weight:500;">@(item.ProductName ?? "—")</td>
<td style="padding:6px 8px;text-align:center;">@item.Quantity</td>
<td style="padding:6px 8px;text-align:right;">@ShopHelpers.FormatVND(item.UnitPrice)</td>
<td style="padding:6px 8px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(item.Subtotal)</td>
</tr>
}
</tbody></table>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button @onclick="@(() => CancelOrderItem(o.Id))" style="padding:6px 14px;border-radius:8px;border:1px solid rgba(239,68,68,0.3);background:rgba(239,68,68,0.1);color:#EF4444;font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="x-circle" style="width:12px;height:12px;"></i> Hủy đơn
</button>
</div>
</div>
</td></tr>
}
}
</tbody></table>
</div>
</div>
}
else
{
@RenderEmpty("bar-chart-3", "#22C55E", "Chưa có dữ liệu tài chính", "Dữ liệu sẽ tự động cập nhật khi có đơn hàng", "monitor", "Mở POS bán hàng", $"/pos/{ShopId}/{PosVertical}")
}
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public string PosVertical { get; set; } = "cafe";
// Finance data
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.WalletInfo> _wallets = new();
private List<PosDataService.WalletTxnInfo> _walletTxns = new();
// Finance date range filter state
private string _financePeriod = "all"; // 7d, 30d, all
// Order detail state
private Guid? _selectedOrderId;
private PosDataService.OrderDetailResponse? _orderDetail;
protected override async Task OnInitializedAsync()
{
_orders = await DataService.GetOrdersAsync(ShopId == Guid.Empty ? null : ShopId);
_wallets = await DataService.GetWalletsAsync();
_walletTxns = await DataService.GetWalletTransactionsAsync();
}
// ═══ ORDER DETAIL ═══
private async Task ViewOrderDetail(Guid orderId)
{
if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; }
_selectedOrderId = orderId;
try { _orderDetail = await DataService.GetOrderDetailAsync(orderId, ShopId == Guid.Empty ? null : ShopId); }
catch { _orderDetail = null; }
}
private async Task CancelOrderItem(Guid orderId)
{
var ok = await DataService.CancelOrderAsync(orderId);
if (ok)
{
_selectedOrderId = null;
_orderDetail = null;
_orders = await DataService.GetOrdersAsync(ShopId == Guid.Empty ? null : ShopId);
}
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({ShopHelpers.HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
@if (ctaIcon != null && ctaLabel != null)
{
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace WebClientTpos.Client.Pages.Admin.Shop;
/// <summary>
/// EN: Shared static helpers used across Shop sub-components.
/// VI: Các helper tĩnh dùng chung cho các component con của Shop.
/// </summary>
public static class ShopHelpers
{
public static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
public static string HexToRgb(string hex)
{
hex = hex.TrimStart('#');
if (hex.Length != 6) return "0,0,0";
return $"{Convert.ToInt32(hex[..2], 16)},{Convert.ToInt32(hex[2..4], 16)},{Convert.ToInt32(hex[4..], 16)}";
}
public static string DayLabel(int dow) => dow switch
{
0 => "CN", 1 => "T2", 2 => "T3", 3 => "T4",
4 => "T5", 5 => "T6", 6 => "T7", _ => $"#{dow}"
};
public 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";
public 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

@@ -0,0 +1,312 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div>
@if (!_inventory.Any() && _invSubTab == "levels")
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="warehouse" style="width:36px;height:36px;color:#3B82F6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có tồn kho</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Tồn kho sẽ hiển thị khi có sản phẩm</p>
</div>
}
else
{
<!-- Stats cards -->
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 10)</span><span class="admin-stat-card__label">Còn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="alert-triangle" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity > 0 && i.Quantity <= 10)</span><span class="admin-stat-card__label">Sắp hết</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);"><i data-lucide="x-circle" style="color:#EF4444;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count(i => i.Quantity <= 0)</span><span class="admin-stat-card__label">Hết hàng</span></div></div>
</div>
<!-- Sub-tabs -->
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;margin-top:16px;">
@foreach (var (label, val, icon) in new[] { ("Tồn kho", "levels", "package"), ("Nhập kho", "stock-in", "arrow-down-to-line"), ("Xuất kho", "stock-out", "arrow-up-from-line"), ("Điều chỉnh", "adjust", "settings-2"), ("Lịch sử", "transactions", "history"), ("Cảnh báo", "low-stock", "alert-triangle") })
{
<button @onclick="@(() => SwitchInvSubTab(val))"
style="padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;display:flex;align-items:center;gap:6px;
@(_invSubTab == val ? "background:var(--admin-orange-primary);color:#FFF;box-shadow:0 1px 3px rgba(0,0,0,0.1);" : "background:transparent;color:var(--admin-text-tertiary);")">
<i data-lucide="@icon" style="width:14px;height:14px;"></i> @label
</button>
}
</div>
@if (_invFormMessage != null)
{
<div style="margin-top:12px;padding:12px 16px;border-radius:8px;font-size:13px;font-weight:500;background:@(_invFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_invFormSuccess ? "#16A34A" : "#DC2626");">
@_invFormMessage
</div>
}
@switch (_invSubTab)
{
case "levels":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mức nhập lại</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thao tác</th>
</tr></thead><tbody>
@foreach (var item in _inventory)
{
var qtyColor = item.Quantity <= 0 ? "#EF4444" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "#F59E0B" : "#22C55E";
var bgColor = item.Quantity <= 0 ? "rgba(239,68,68,0.05)" : item.Quantity <= (item.ReorderLevel > 0 ? item.ReorderLevel : 10) ? "rgba(245,158,11,0.05)" : "transparent";
<tr style="border-top:1px solid var(--admin-border-subtle);background:@bgColor;">
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@qtyColor;">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.ReorderLevel</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">+Nhập</button>
<button @onclick="@(() => { _invSubTab = "stock-out"; _invSelectedProductId = item.ProductId; _invAmount = 0; _invNotes = ""; StateHasChanged(); })"
style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;color:#DC2626;cursor:pointer;">-Xuất</button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
break;
case "stock-in":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-down-to-line" style="width:18px;height:18px;margin-right:8px;color:#22C55E;"></i>Nhập kho</h3></div>
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng nhập *</label>
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Lý do nhập kho..." />
</div>
<button @onclick="DoStockIn" style="padding:10px 20px;background:#22C55E;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="arrow-down-to-line" style="width:16px;height:16px;margin-right:6px;"></i>Nhập kho
</button>
</div>
</div>
break;
case "stock-out":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="arrow-up-from-line" style="width:18px;height:18px;margin-right:8px;color:#EF4444;"></i>Xuất kho</h3></div>
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng xuất *</label>
<input type="number" @bind="_invAmount" min="1" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Nhập số lượng..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Ghi chú</label>
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Lý do xuất kho..." />
</div>
<button @onclick="DoStockOut" style="padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="arrow-up-from-line" style="width:16px;height:16px;margin-right:6px;"></i>Xuất kho
</button>
</div>
</div>
break;
case "adjust":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="settings-2" style="width:18px;height:18px;margin-right:8px;color:#3B82F6;"></i>Điều chỉnh tồn kho</h3></div>
<div class="admin-panel__body" style="display:grid;gap:16px;max-width:500px;">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Sản phẩm *</label>
<select @bind="_invSelectedProductId" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="@Guid.Empty">-- Chọn sản phẩm --</option>
@foreach (var item in _inventory)
{
<option value="@item.ProductId">@(item.ProductName ?? item.ProductId.ToString()[..8]) (Tồn: @item.Quantity)</option>
}
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Số lượng mới *</label>
<input type="number" @bind="_invNewQty" min="0" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Đặt số lượng chính xác..." />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">Lý do *</label>
<input type="text" @bind="_invNotes" style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle);border-radius:8px;font-size:14px;background:var(--admin-bg-elevated);color:var(--admin-text-primary);" placeholder="Lý do điều chỉnh..." />
</div>
<button @onclick="DoAdjustStock" style="padding:10px 20px;background:#3B82F6;color:white;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">
<i data-lucide="settings-2" style="width:16px;height:16px;margin-right:6px;"></i>Điều chỉnh
</button>
</div>
</div>
break;
case "transactions":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="history" style="width:18px;height:18px;margin-right:8px;color:#8B5CF6;"></i>Lịch sử giao dịch kho</h3></div>
<div class="admin-panel__body" style="padding:0;">
@if (_invTxns.Any())
{
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thời gian</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Lý do</th>
</tr></thead><tbody>
@foreach (var tx in _invTxns.OrderByDescending(t => t.CreatedAt).Take(50))
{
var txColor = tx.QuantityChange > 0 ? "#22C55E" : tx.QuantityChange < 0 ? "#EF4444" : "#6B7280";
var txLabel = tx.TransactionType switch { "StockIn" => "Nhập kho", "StockOut" => "Xuất kho", "Adjustment" => "Điều chỉnh", "OrderDeduction" => "Đơn hàng", _ => tx.TransactionType ?? "N/A" };
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@tx.CreatedAt.ToLocalTime().ToString("dd/MM HH:mm")</td>
<td style="padding:12px 16px;">
<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:@(txColor)1a;color:@txColor;">@txLabel</span>
</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@txColor;">@(tx.QuantityChange > 0 ? "+" : "")@tx.QuantityChange</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(tx.Reason ?? "—")</td>
</tr>
}
</tbody></table>
}
else
{
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có giao dịch kho nào.</div>
}
</div>
</div>
break;
case "low-stock":
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header">
<h3 style="margin:0;font-size:16px;font-weight:700;"><i data-lucide="alert-triangle" style="width:18px;height:18px;margin-right:8px;color:#F59E0B;"></i>Cảnh báo tồn kho thấp</h3>
<button @onclick="LoadLowStock" style="padding:6px 14px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:var(--admin-text-secondary);">
<i data-lucide="refresh-cw" style="width:12px;height:12px;margin-right:4px;"></i>Làm mới
</button>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_lowStockItems.Any())
{
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tồn kho</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngưỡng</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hành động</th>
</tr></thead><tbody>
@foreach (var item in _lowStockItems)
{
<tr style="border-top:1px solid var(--admin-border-subtle);background:rgba(245,158,11,0.03);">
<td style="padding:12px 16px;font-weight:600;">@(item.ProductName ?? item.ProductId.ToString()[..8])</td>
<td style="padding:12px 16px;text-align:right;font-weight:700;color:@(item.Quantity <= 0 ? "#EF4444" : "#F59E0B");">@item.Quantity</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;color:var(--admin-text-tertiary);">@item.LowStockThreshold</td>
<td style="padding:12px 16px;text-align:center;">
<button @onclick="@(() => { _invSubTab = "stock-in"; _invSelectedProductId = item.ProductId; _invAmount = item.LowStockThreshold * 2; _invNotes = "Bổ sung hàng tồn kho thấp"; StateHasChanged(); })"
style="background:rgba(34,197,94,0.1);border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;color:#16A34A;cursor:pointer;">
<i data-lucide="arrow-down-to-line" style="width:12px;height:12px;margin-right:4px;"></i>Nhập kho nhanh
</button>
</td>
</tr>
}
</tbody></table>
}
else
{
<div style="text-align:center;padding:32px;color:var(--admin-text-tertiary);font-size:14px;">
<i data-lucide="check-circle" style="width:32px;height:32px;color:#22C55E;margin-bottom:8px;"></i><br/>
Tất cả sản phẩm đều đủ hàng!
</div>
}
</div>
</div>
break;
}
}
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
private Guid? _shopGuid => ShopId != Guid.Empty ? ShopId : null;
private List<PosDataService.InventoryItemInfo> _inventory = new();
private List<PosDataService.InventoryTxnInfo> _invTxns = new();
private string _invSubTab = "levels";
private Guid _invSelectedProductId;
private int _invAmount;
private int _invNewQty;
private string _invNotes = "";
private string? _invFormMessage;
private bool _invFormSuccess;
private List<PosDataService.LowStockItemInfo> _lowStockItems = new();
protected override async Task OnInitializedAsync()
{
_inventory = await DataService.GetInventoryAsync(_shopGuid);
_invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid);
}
private async Task SwitchInvSubTab(string tab)
{
_invSubTab = tab;
_invFormMessage = null;
if (tab == "low-stock") await LoadLowStock();
StateHasChanged();
}
private async Task LoadLowStock()
{
_lowStockItems = await DataService.GetLowStockAsync(_shopGuid);
StateHasChanged();
}
private async Task DoStockIn()
{
_invFormMessage = null;
if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; }
var ok = await DataService.StockInAsync(new PosDataService.StockInRequest(_invSelectedProductId, ShopId, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
_invFormMessage = ok ? $"Đã nhập kho thành công +{_invAmount}!" : "Lỗi khi nhập kho.";
_invFormSuccess = ok;
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
}
private async Task DoStockOut()
{
_invFormMessage = null;
if (_invSelectedProductId == Guid.Empty || _invAmount <= 0) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập số lượng > 0."; _invFormSuccess = false; return; }
var ok = await DataService.StockOutAsync(new PosDataService.StockOutRequest(_invSelectedProductId, ShopId, _invAmount, string.IsNullOrWhiteSpace(_invNotes) ? null : _invNotes));
_invFormMessage = ok ? $"Đã xuất kho thành công -{_invAmount}!" : "Lỗi khi xuất kho. Kiểm tra số lượng tồn.";
_invFormSuccess = ok;
if (ok) { _invAmount = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
}
private async Task DoAdjustStock()
{
_invFormMessage = null;
if (_invSelectedProductId == Guid.Empty || string.IsNullOrWhiteSpace(_invNotes)) { _invFormMessage = "Vui lòng chọn sản phẩm và nhập lý do điều chỉnh."; _invFormSuccess = false; return; }
var ok = await DataService.AdjustStockAsync(new PosDataService.AdjustStockRequest(_invSelectedProductId, ShopId, _invNewQty, _invNotes));
_invFormMessage = ok ? $"Đã điều chỉnh tồn kho = {_invNewQty}!" : "Lỗi khi điều chỉnh.";
_invFormSuccess = ok;
if (ok) { _invNewQty = 0; _invNotes = ""; _inventory = await DataService.GetInventoryAsync(_shopGuid); _invTxns = await DataService.GetInventoryTransactionsAsync(_shopGuid); }
}
}

View File

@@ -0,0 +1,115 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
@foreach (var st in new[] { ("all", "", "Tất cả"), ("pending", "clock", "Chờ"), ("preparing", "flame", "Đang làm"), ("completed", "check-circle", "Xong") })
{
<button @onclick='() => LoadKitchenTickets(st.Item1)'
style="padding:6px 14px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer;transition:all 0.2s;@(_kitchenStatusFilter == st.Item1 ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
@if (!string.IsNullOrEmpty(st.Item2)) { <i data-lucide="@st.Item2" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i> }@st.Item3
</button>
}
</div>
<div style="display:flex;gap:8px;">
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Chờ: @_kitchenTickets.Count(t => t.Status == "pending")</span>
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang làm: @_kitchenTickets.Count(t => t.Status == "preparing")</span>
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Xong: @_kitchenTickets.Count(t => t.Status == "completed")</span>
</div>
</div>
@if (!_kitchenTickets.Any())
{
@RenderEmpty("flame", "#F59E0B", "Không có ticket bếp", "Ticket sẽ xuất hiện khi có đơn từ POS")
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
@foreach (var ticket in _kitchenTickets)
{
var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "preparing" => "#3B82F6", "completed" => "#22C55E", _ => "#6B6B6F" };
var ticketLabel = ticket.Status switch { "pending" => "Chờ", "preparing" => "Đang làm", "completed" => "Hoàn thành", _ => ticket.Status };
var elapsed = (DateTime.UtcNow - ticket.CreatedAt).TotalMinutes;
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-top:4px solid @ticketColor;border-radius:12px;padding:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span style="font-weight:700;font-size:13px;font-family:monospace;">P@(ticket.Priority)</span>
<span style="font-size:10px;font-weight:700;padding:3px 10px;border-radius:6px;background:@($"{ticketColor}22");color:@ticketColor;">@ticketLabel</span>
</div>
<div style="font-size:14px;font-weight:600;margin-bottom:4px;">@ticket.ItemName</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:12px;">@(ticket.Station ?? "Bếp chính")</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--admin-border-subtle);">
<span style="font-size:11px;color:var(--admin-text-tertiary);"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @((int)elapsed) phút</span>
@if (ticket.Status != "completed")
{
<button @onclick='() => MarkTicketDone(ticket.Id)' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(34,197,94,0.15);color:#22C55E;font-size:11px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:3px;"><i data-lucide="check" style="width:11px;height:11px;"></i> Xong</button>
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
// Kitchen state
private List<PosDataService.KitchenTicketInfo> _kitchenTickets = new();
private string _kitchenStatusFilter = "all";
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId);
}
// ═══ KITCHEN ═══
private async Task LoadKitchenTickets(string status)
{
_kitchenStatusFilter = status;
try
{
if (ShopId != Guid.Empty)
_kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, status);
}
catch (Exception ex) { _errorMessage = $"Không thể tải kitchen tickets: {ex.Message}"; }
StateHasChanged();
}
private async Task MarkTicketDone(Guid ticketId)
{
try
{
await DataService.UpdateTicketStatusAsync(ticketId, new PosDataService.UpdateTicketStatusRequest("completed"));
if (ShopId != Guid.Empty)
_kitchenTickets = await DataService.GetKitchenTicketsAsync(ShopId, _kitchenStatusFilter);
}
catch (Exception ex) { _errorMessage = $"Không thể cập nhật trạng thái: {ex.Message}"; }
StateHasChanged();
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
@if (ctaIcon != null && ctaLabel != null)
{
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};
private static string HexToRgb(string hex)
{
hex = hex.TrimStart('#');
if (hex.Length != 6) return "0,0,0";
return $"{Convert.ToInt32(hex[..2], 16)},{Convert.ToInt32(hex[2..4], 16)},{Convert.ToInt32(hex[4..], 16)}";
}
}

View File

@@ -0,0 +1,388 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(FilteredProducts.Count) sản phẩm</h3>
<div style="display:flex;align-items:center;gap:8px;">
@if (_categories.Any())
{
<select @bind="_productCategoryFilter" @bind:after="@(() => _productPage = 1)" style="padding:6px 10px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;">
<option value="">Tất cả danh mục</option>
@foreach (var c in _categories) { <option value="@c.Id">@c.Name</option> }
</select>
}
<div style="display:flex;border:1px solid var(--admin-border-subtle);border-radius:8px;overflow:hidden;">
<button @onclick='() => _productView = "grid"' style="padding:6px 10px;border:none;cursor:pointer;background:@(_productView == "grid" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "grid" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="grid-3x3" style="width:14px;height:14px;"></i></button>
<button @onclick='() => _productView = "list"' style="padding:6px 10px;border:none;border-left:1px solid var(--admin-border-subtle);cursor:pointer;background:@(_productView == "list" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "list" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="list" style="width:14px;height:14px;"></i></button>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _newProductCategoryId = ""; _formMessage = null; _productImageFile = null; _productImagePreview = null; _showProductForm = !_showProductForm; })">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
Thêm sản phẩm
</button>
</div>
</div>
@if (_showProductForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(255,92,0,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingProductId.HasValue ? "Chỉnh sửa sản phẩm" : "Thêm sản phẩm mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên sản phẩm *</label><input type="text" @bind="_newProductName" class="admin-input" placeholder="VD: Cà phê sữa đá" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giá (₫) *</label><input type="number" @bind="_newProductPrice" class="admin-input" placeholder="35000" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại</label>
<select @bind="_newProductType" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="PreparedFood">Đồ uống / Thức ăn</option>
<option value="Service">Dịch vụ</option>
<option value="Physical">Sản phẩm vật lý</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newProductDesc" class="admin-input" placeholder="Mô tả ngắn" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Danh mục</label>
<select @bind="_newProductCategoryId" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="">-- Không phân loại --</option>
@foreach (var c in _categories)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hình ảnh</label>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
@if (_productImagePreview != null)
{
<img src="@_productImagePreview" alt="Preview" style="max-width:80px;max-height:80px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnProductImageSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="@(_editingProductId.HasValue ? SaveProduct : AddProduct)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingProductId.HasValue ? "Cập nhật" : "Lưu")</button>
<button class="admin-btn" @onclick="@(() => _showProductForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (!string.IsNullOrEmpty(_formMessage))
{
<div style="margin-top:8px;font-size:13px;color:@(_formSuccess ? "#22C55E" : "#EF4444");">@_formMessage</div>
}
</div>
</div>
}
@if (!_products.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(245,158,11,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="coffee" style="width:36px;height:36px;color:#F59E0B;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có sản phẩm</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Thêm sản phẩm để bắt đầu bán hàng</p>
</div>
}
else if (_productView == "grid")
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
@foreach (var p in PagedProducts)
{
var typeColor = (p.Type ?? "") switch { "Service" => "#EC4899", "Physical" => "#3B82F6", _ => "#F59E0B" };
var typeLabel = (p.Type ?? "") switch { "Service" => "Dịch vụ", "Physical" => "Vật lý", _ => "Đồ uống" };
<div class="admin-panel" style="position:relative;">
<div class="admin-panel__body" style="padding:16px;text-align:center;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick="@(() => EditProduct(p))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteProduct(p.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
@if (!string.IsNullOrWhiteSpace(p.ImageUrl))
{
<img src="@p.ImageUrl" alt="@p.Name" style="width:48px;height:48px;border-radius:12px;object-fit:cover;margin:0 auto 12px;" />
}
else
{
<div style="width:48px;height:48px;border-radius:12px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;"><i data-lucide="package" style="color:#FF5C00;width:24px;height:24px;"></i></div>
}
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">@p.Name</div>
<div style="font-size:13px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(p.CategoryName ?? "—")</div>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:6px;background:@($"{typeColor}22");color:@typeColor;">@typeLabel</span>
<div style="font-weight:700;color:var(--admin-orange-primary);margin-top:8px;">@p.Price.ToString("N0")₫</div>
</div>
</div>
}
</div>
}
else
{
@* LIST VIEW *@
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Danh mục</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var p in PagedProducts)
{
var typeColor = (p.Type ?? "") switch { "Service" => "#EC4899", "Physical" => "#3B82F6", _ => "#F59E0B" };
var typeLabel = (p.Type ?? "") switch { "Service" => "Dịch vụ", "Physical" => "Vật lý", _ => "Đồ uống" };
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@p.Name</td>
<td style="padding:12px 16px;color:var(--admin-text-secondary);font-size:13px;">@(p.CategoryName ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span style="font-size:11px;padding:2px 8px;border-radius:4px;background:@($"{typeColor}22");color:@typeColor;font-weight:600;">@typeLabel</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@p.Price.ToString("N0")₫</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => EditProduct(p))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteProduct(p.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@* ═══ PAGINATION ═══ *@
@if (ProductTotalPages > 1)
{
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin-top:16px;">
<button disabled="@(_productPage <= 1)" @onclick="@(() => _productPage--)"
style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-secondary);cursor:pointer;font-size:12px;">
<i data-lucide="chevron-left" style="width:14px;height:14px;"></i>
</button>
@for (var i = 1; i <= ProductTotalPages; i++)
{
var pg = i;
<button @onclick="@(() => _productPage = pg)"
style="min-width:32px;height:32px;border-radius:8px;border:1px solid @(pg == _productPage ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");background:@(pg == _productPage ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(pg == _productPage ? "#FFF" : "var(--admin-text-secondary)");cursor:pointer;font-size:12px;font-weight:600;">
@pg
</button>
}
<button disabled="@(_productPage >= ProductTotalPages)" @onclick="@(() => _productPage++)"
style="padding:6px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-secondary);cursor:pointer;font-size:12px;">
<i data-lucide="chevron-right" style="width:14px;height:14px;"></i>
</button>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">Trang @_productPage / @ProductTotalPages</span>
</div>
}
@* Categories Management *@
<div class="admin-panel" style="margin-top:20px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Danh mục (@_categories.Count)</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingCategoryId = null; _newCategoryName = ""; _newCategoryDesc = ""; _newCategoryOrder = 0; _categoryFormMessage = null; _categoryImageFile = null; _categoryImagePreview = null; _newCategoryImageUrl = ""; _showCategoryForm = !_showCategoryForm; })">
<i data-lucide="plus" style="width:16px;height:16px;"></i>Thêm danh mục
</button>
</div>
@if (_showCategoryForm)
{
<div style="padding:16px;border-bottom:1px solid var(--admin-border-subtle);">
<h4 style="margin:0 0 12px;color:var(--admin-text-primary);">@(_editingCategoryId.HasValue ? "Chỉnh sửa danh mục" : "Thêm danh mục mới")</h4>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;align-items:end;">
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Tên</label><input @bind="_newCategoryName" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Mô tả</label><input @bind="_newCategoryDesc" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Hình ảnh</label>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:8px;text-align:center;">
@if (_categoryImagePreview != null) { <img src="@_categoryImagePreview" alt="Preview" style="max-width:60px;max-height:60px;border-radius:6px;margin-bottom:4px;" /> }
<InputFile OnChange="OnCategoryImageSelected" accept="image/*" style="font-size:11px;width:100%;" />
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="@SaveCategory" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingCategoryId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick="@(() => _showCategoryForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_categoryFormMessage != null) { <div style="margin-top:8px;padding:8px 12px;border-radius:6px;font-size:13px;@(_categoryFormSuccess ? "color:#22C55E;background:rgba(34,197,94,0.1);" : "color:#EF4444;background:rgba(239,68,68,0.1);")">@_categoryFormMessage</div> }
</div>
}
<div style="padding:16px;">
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="font-size:12px;color:var(--admin-text-tertiary);text-align:left;">
<th style="padding:8px 12px;">TÊN</th><th style="padding:8px 12px;">MÔ TẢ</th><th style="padding:8px 12px;">THỨ TỰ</th><th style="padding:8px 12px;text-align:right;">HÀNH ĐỘNG</th>
</tr></thead>
<tbody>
@foreach (var c in _categories)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:10px 12px;color:var(--admin-text-primary);font-weight:500;">@c.Name</td>
<td style="padding:10px 12px;color:var(--admin-text-secondary);font-size:13px;">@c.Description</td>
<td style="padding:10px 12px;color:var(--admin-text-secondary);">@c.DisplayOrder</td>
<td style="padding:10px 12px;text-align:right;">
<div style="display:flex;gap:6px;justify-content:flex-end;">
<button @onclick="@(() => EditCategory(c))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteCategoryItem(c.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
@if (!_categories.Any()) { <div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có danh mục. Nhấn "Thêm danh mục" để tạo mới.</div> }
</div>
</div>
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
private Guid? _shopGuid => ShopId != Guid.Empty ? ShopId : null;
// Product state
private List<PosDataService.AdminProductInfo> _products = new();
private bool _showProductForm;
private Guid? _editingProductId;
private string _newProductName = "";
private decimal _newProductPrice;
private string _newProductType = "PreparedFood";
private string _newProductDesc = "";
private string _newProductCategoryId = "";
private string? _formMessage;
private bool _formSuccess;
private int _productPage = 1;
private int _productPageSize = 20;
private string _productView = "grid";
private string _productCategoryFilter = "";
private int ProductTotalPages => Math.Max(1, (int)Math.Ceiling((double)FilteredProducts.Count / _productPageSize));
private List<PosDataService.AdminProductInfo> FilteredProducts =>
string.IsNullOrEmpty(_productCategoryFilter) ? _products :
_products.Where(p => (p.CategoryId?.ToString() ?? "") == _productCategoryFilter).ToList();
private List<PosDataService.AdminProductInfo> PagedProducts =>
FilteredProducts.Skip((_productPage - 1) * _productPageSize).Take(_productPageSize).ToList();
// Category state
private List<PosDataService.AdminCategoryInfo> _categories = new();
private bool _showCategoryForm;
private Guid? _editingCategoryId;
private string _newCategoryName = "";
private string _newCategoryDesc = "";
private int _newCategoryOrder;
private string? _categoryFormMessage;
private bool _categoryFormSuccess;
// Image upload state
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _productImageFile;
private string? _productImagePreview;
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _categoryImageFile;
private string? _categoryImagePreview;
private string _newCategoryImageUrl = "";
protected override async Task OnInitializedAsync()
{
_products = await DataService.GetAllProductsAsync(_shopGuid);
_categories = await DataService.GetAllCategoriesAsync(_shopGuid);
}
private async Task AddProduct()
{
_formMessage = null;
if (string.IsNullOrWhiteSpace(_newProductName) || _newProductPrice <= 0 || ShopId == Guid.Empty)
{
_formMessage = "Vui lòng nhập tên và giá sản phẩm."; _formSuccess = false; return;
}
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid) ? cid : null;
var imgUrl = await UploadFileIfNeeded(_productImageFile);
await DataService.CreateProductAsync(new PosDataService.CreateProductRequest(
ShopId, _newProductName, _newProductDesc, _newProductPrice, _newProductType, imgUrl, null, catId));
_formMessage = $"Đã thêm '{_newProductName}' thành công!"; _formSuccess = true;
_newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductCategoryId = ""; _productImageFile = null; _productImagePreview = null;
_products = await DataService.GetAllProductsAsync(_shopGuid);
}
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
}
private async Task DeleteProduct(Guid productId)
{
try
{
await DataService.DeleteProductAsync(productId);
_products = await DataService.GetAllProductsAsync(_shopGuid);
}
catch (Exception ex) { _formMessage = $"Không thể xóa: {ex.Message}"; _formSuccess = false; }
}
private void EditProduct(PosDataService.AdminProductInfo p)
{
_editingProductId = p.Id;
_newProductName = p.Name;
_newProductPrice = p.Price;
_newProductType = p.Type ?? "PreparedFood";
_newProductDesc = p.Description ?? "";
_newProductCategoryId = p.CategoryId?.ToString() ?? "";
_productImageFile = null;
_productImagePreview = p.ImageUrl;
_formMessage = null;
_showProductForm = true;
}
private async Task SaveProduct()
{
_formMessage = null;
if (string.IsNullOrWhiteSpace(_newProductName) || _newProductPrice <= 0 || ShopId == Guid.Empty || !_editingProductId.HasValue)
{
_formMessage = "Vui lòng nhập tên và giá sản phẩm."; _formSuccess = false; return;
}
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid2) ? cid2 : null;
var imgUrl2 = await UploadFileIfNeeded(_productImageFile);
await DataService.UpdateProductAsync(_editingProductId.Value, new PosDataService.CreateProductRequest(
ShopId, _newProductName, _newProductDesc, _newProductPrice, _newProductType, imgUrl2, null, catId));
_formMessage = $"Đã cập nhật '{_newProductName}' thành công!"; _formSuccess = true;
_editingProductId = null;
_products = await DataService.GetAllProductsAsync(_shopGuid);
}
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
}
private async Task SaveCategory()
{
if (string.IsNullOrWhiteSpace(_newCategoryName)) { _categoryFormMessage = "Tên danh mục không được trống."; _categoryFormSuccess = false; return; }
var catImgUrl = await UploadFileIfNeeded(_categoryImageFile) ?? _newCategoryImageUrl;
var req = new PosDataService.AdminCreateCategoryRequest(_shopGuid ?? Guid.Empty, _newCategoryName, _newCategoryDesc, _newCategoryOrder, string.IsNullOrWhiteSpace(catImgUrl) ? null : catImgUrl);
bool ok;
if (_editingCategoryId.HasValue)
ok = await DataService.UpdateCategoryAsync(_editingCategoryId.Value, req);
else
ok = await DataService.CreateCategoryAsync(req);
_categoryFormMessage = ok ? (_editingCategoryId.HasValue ? "Đã cập nhật danh mục!" : "Đã thêm danh mục!") : "Lỗi khi lưu danh mục.";
_categoryFormSuccess = ok;
if (ok) { _showCategoryForm = false; _categoryImageFile = null; _categoryImagePreview = null; _newCategoryImageUrl = ""; _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
}
private void EditCategory(PosDataService.AdminCategoryInfo c)
{
_editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _newCategoryImageUrl = c.ImageUrl ?? ""; _categoryImageFile = null; _categoryImagePreview = c.ImageUrl; _showCategoryForm = true; _categoryFormMessage = null;
}
private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
private async Task OnProductImageSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_productImageFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 200, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_productImagePreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task OnCategoryImageSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_categoryImageFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 200, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_categoryImagePreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task<string?> UploadFileIfNeeded(Microsoft.AspNetCore.Components.Forms.IBrowserFile? file)
{
if (file == null) return null;
using var stream = file.OpenReadStream(maxAllowedSize: 10_485_760);
return await DataService.UploadImageAsync(stream, file.Name, file.ContentType);
}
}

View File

@@ -0,0 +1,268 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="padding:8px 14px;border-radius:8px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.15);margin-bottom:12px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--admin-text-secondary);">
<i data-lucide="info" style="width:14px;height:14px;color:#3B82F6;flex-shrink:0;"></i>
Chiến dịch khuyến mãi chung cho tất cả cửa hàng trong thương hiệu
</div>
@* ─── Sub-tabs: Campaigns | Vouchers ─── *@
<div style="display:flex;gap:8px;margin-bottom:16px;">
@{ var promoTabs = new[] { ("campaigns", "Chiến dịch", "tag"), ("vouchers", "Mã voucher", "ticket") }; }
@foreach (var (tab, label, icon) in promoTabs)
{
var t = tab;
var isActive = _promoSubTab == t;
<button @onclick="@(() => SwitchPromoTab(t))"
class="@(isActive ? "admin-btn-primary" : "")"
style="padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);font-size:13px;display:inline-flex;align-items:center;gap:6px;cursor:pointer;@(isActive ? "background:var(--admin-orange-primary);color:#FFF;font-weight:700;border-color:var(--admin-orange-primary);" : "background:var(--admin-bg-elevated);color:var(--admin-text-secondary);font-weight:500;")">
<i data-lucide="@icon" style="width:14px;height:14px;"></i>@label
</button>
}
</div>
@if (_promoSubTab == "campaigns")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_campaigns.Count chiến dịch</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showCampaignForm = !_showCampaignForm; _editingCampaignId = null; _newCampaignName = ""; _newCampaignDesc = ""; _newCampaignValue = 0; _newCampaignVouchers = 0; _newCampaignDiscountType = "fixed"; _newCampaignStart = DateTime.Today; _newCampaignEnd = DateTime.Today.AddMonths(1); _campaignFormMessage = null; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm chiến dịch
</button>
</div>
@if (_showCampaignForm)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingCampaignId.HasValue ? "Sửa chiến dịch" : "Thêm chiến dịch")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên chiến dịch *</label><input type="text" @bind="_newCampaignName" placeholder="Tên chiến dịch..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newCampaignDesc" placeholder="Mô tả..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại giảm giá *</label>
<select @bind="_newCampaignDiscountType" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="fixed">Theo giá tiền (₫)</option>
<option value="percentage">Theo phần trăm (%)</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giá trị @(_newCampaignDiscountType == "percentage" ? "(%)" : "(₫)") *</label><input type="number" @bind="_newCampaignValue" min="0" max="@(_newCampaignDiscountType == "percentage" ? 100 : 999999999)" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số lượng voucher *</label><input type="number" @bind="_newCampaignVouchers" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày bắt đầu *</label><input type="date" @bind="_newCampaignStart" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày kết thúc *</label><input type="date" @bind="_newCampaignEnd" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
</div>
@if (_campaignFormMessage != null)
{
<div style="margin-top:12px;padding:8px 12px;border-radius:8px;background:@(_campaignFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_campaignFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_campaignFormMessage</div>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="SaveCampaign">Lưu</button>
<button style="padding:6px 16px;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;" @onclick='() => { _showCampaignForm = false; _campaignFormMessage = null; }'>Hủy</button>
</div>
</div>
</div>
}
@if (!_campaigns.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="tag" style="width:36px;height:36px;color:#22C55E;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có chiến dịch</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Tạo chiến dịch voucher, khuyến mãi cho khách hàng</p>
<a href="/admin/shop/@ShopId/menu" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
Thêm chiến dịch
</a>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="tag" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Count</span><span class="admin-stat-card__label">Tổng chiến dịch</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="zap" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Count(c => c.Status == "Active")</span><span class="admin-stat-card__label">Đang hoạt động</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="ticket" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Sum(c => c.TotalVouchers)</span><span class="admin-stat-card__label">Tổng voucher</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="check-circle" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_campaigns.Sum(c => c.IssuedVouchers)</span><span class="admin-stat-card__label">Đã phát</span></div></div>
</div>
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách chiến dịch</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá trị</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đã phát/Tổng</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Bắt đầu</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Kết thúc</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var c in _campaigns)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@c.Name</td>
<td style="padding:12px 16px;text-align:center;"><span style="font-size:11px;padding:2px 8px;border-radius:4px;background:@(c.Description?.Contains("%") == true ? "rgba(139,92,246,0.15)" : "rgba(255,92,0,0.15)");color:@(c.Description?.Contains("%") == true ? "#8B5CF6" : "#FF5C00");font-weight:600;">@(c.Description?.Contains("%") == true ? "%" : "₫")</span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@(c.Description?.Contains("%") == true ? $"{c.FaceValue}%" : ShopHelpers.FormatVND(c.FaceValue))</td>
<td style="padding:12px 16px;text-align:center;font-size:13px;">@c.IssuedVouchers / @c.TotalVouchers</td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(c.StartDate?.ToString("dd/MM/yy") ?? "—")</td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(c.EndDate?.ToString("dd/MM/yy") ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => EditCampaign(c))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteCampaignItem(c.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
} @* end else *@
} @* end campaigns sub-tab *@
@if (_promoSubTab == "vouchers")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_vouchers.Count mã voucher</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="LoadVouchers">
<i data-lucide="refresh-cw" style="width:14px;height:14px;margin-right:4px;"></i>Làm mới
</button>
</div>
@if (!_vouchers.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(236,72,153,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="ticket" style="width:36px;height:36px;color:#EC4899;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có voucher</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Tạo chiến dịch để tự động sinh mã voucher</p>
<a href="/admin/shop/@ShopId/menu" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="tag" style="width:16px;height:16px;"></i>
Tạo chiến dịch
</a>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách mã voucher</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mã</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Chiến dịch</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mệnh giá</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Còn lại</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tạo</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var v in _vouchers)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;font-family:monospace;letter-spacing:1px;color:var(--admin-orange-primary);">@v.Code</td>
<td style="padding:12px 16px;font-size:13px;">@(v.CampaignName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@ShopHelpers.FormatVND(v.FaceValue)</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(v.RemainingValue < v.FaceValue ? "#F59E0B" : "var(--admin-text-primary)");">@ShopHelpers.FormatVND(v.RemainingValue)</td>
<td style="padding:12px 16px;text-align:center;"><span style="font-size:11px;padding:2px 8px;border-radius:4px;background:@(GetVoucherStatusColor(v.Status));font-weight:600;">@GetVoucherStatusLabel(v.Status)</span></td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(v.CreatedAt?.ToLocalTime().ToString("dd/MM/yy") ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
@if (v.Status?.ToLower() == "available")
{
<button @onclick="@(() => RevokeVoucher(v.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;padding:4px 10px;font-size:11px;color:#EF4444;font-weight:600;cursor:pointer;" title="Thu hồi">Thu hồi</button>
}
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
}
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.CampaignInfo> _campaigns = new();
private bool _showCampaignForm;
private Guid? _editingCampaignId;
private string _newCampaignName = "";
private string _newCampaignDesc = "";
private decimal _newCampaignValue;
private int _newCampaignVouchers;
private DateTime _newCampaignStart = DateTime.Today;
private DateTime _newCampaignEnd = DateTime.Today.AddMonths(1);
private string _newCampaignDiscountType = "fixed";
private string? _campaignFormMessage;
private bool _campaignFormSuccess;
private string _promoSubTab = "campaigns";
private List<PosDataService.AdminVoucherInfo> _vouchers = new();
private Guid? _voucherCampaignFilter;
protected override async Task OnInitializedAsync()
{
_campaigns = await DataService.GetCampaignsAsync();
}
private async Task SwitchPromoTab(string tab)
{
_promoSubTab = tab;
if (tab == "vouchers" && !_vouchers.Any())
{
_vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
StateHasChanged();
}
}
private async Task LoadVouchers()
{
_vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
StateHasChanged();
}
private async Task RevokeVoucher(Guid voucherId)
{
var ok = await DataService.RevokeVoucherAsync(voucherId);
if (ok) _vouchers = await DataService.GetAdminVouchersAsync(_voucherCampaignFilter);
}
private static string GetVoucherStatusLabel(string? status) => (status ?? "").ToLower() switch
{
"available" => "Chưa nhận", "claimed" => "Đã nhận", "redeemed" => "Đã dùng",
"revoked" => "Thu hồi", "expired" => "Hết hạn", _ => status ?? "—"
};
private static string GetVoucherStatusColor(string? status) => (status ?? "").ToLower() switch
{
"available" => "#F59E0B", "claimed" => "#3B82F6", "redeemed" => "#22C55E",
"revoked" => "#EF4444", "expired" => "#888", _ => "#888"
};
private async Task SaveCampaign()
{
_campaignFormMessage = null;
if (string.IsNullOrWhiteSpace(_newCampaignName) || _newCampaignValue <= 0 || _newCampaignVouchers <= 0)
{
_campaignFormMessage = "Vui lòng nhập đầy đủ tên, giá trị và số lượng voucher."; _campaignFormSuccess = false; return;
}
var desc = string.IsNullOrWhiteSpace(_newCampaignDesc) ? _newCampaignDiscountType : $"{_newCampaignDesc} [{_newCampaignDiscountType}]";
if (_newCampaignDiscountType == "percentage") desc = $"Giảm {_newCampaignValue}% [{_newCampaignDiscountType}]";
var req = new PosDataService.CreateCampaignRequest(_newCampaignName, desc, _newCampaignValue, _newCampaignVouchers, _newCampaignStart, _newCampaignEnd);
bool ok;
if (_editingCampaignId.HasValue)
ok = await DataService.UpdateCampaignAsync(_editingCampaignId.Value, req);
else
ok = await DataService.CreateCampaignAsync(req);
_campaignFormMessage = ok ? (_editingCampaignId.HasValue ? "Đã cập nhật chiến dịch!" : "Đã thêm chiến dịch!") : "Lỗi khi lưu chiến dịch.";
_campaignFormSuccess = ok;
if (ok) { _showCampaignForm = false; _editingCampaignId = null; _campaigns = await DataService.GetCampaignsAsync(); }
}
private void EditCampaign(PosDataService.CampaignInfo c)
{
_editingCampaignId = c.Id; _newCampaignName = c.Name; _newCampaignDesc = c.Description ?? "";
_newCampaignValue = c.FaceValue; _newCampaignVouchers = c.TotalVouchers;
_newCampaignStart = c.StartDate?.ToLocalTime().Date ?? DateTime.Today;
_newCampaignEnd = c.EndDate?.ToLocalTime().Date ?? DateTime.Today.AddMonths(1);
_showCampaignForm = true; _campaignFormMessage = null;
}
private async Task DeleteCampaignItem(Guid campaignId)
{
await DataService.DeleteCampaignAsync(campaignId);
_campaigns = await DataService.GetCampaignsAsync();
}
}

View File

@@ -0,0 +1,155 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_recipes.Count công thức</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showRecipeForm = !_showRecipeForm; _editingRecipeId = null; _newRecipeName = ""; _newRecipeInstructions = ""; _newRecipePrepTime = 5; _recipeIngredients = new(); _recipeFormMessage = null; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm công thức
</button>
</div>
@if (_showRecipeForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(255,92,0,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingRecipeId.HasValue ? "Chỉnh sửa công thức" : "Thêm công thức mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên công thức *</label><input type="text" @bind="_newRecipeName" placeholder="VD: Cà phê sữa đá" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian chuẩn bị (phút)</label><input type="number" @bind="_newRecipePrepTime" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hướng dẫn</label><textarea @bind="_newRecipeInstructions" rows="2" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);resize:vertical;"></textarea></div>
</div>
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu</span><button @onclick='() => _recipeIngredients.Add(new("","","",0,0))' style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
@for (var idx = 0; idx < _recipeIngredients.Count; idx++)
{
var i = idx;
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
<input value="@_recipeIngredients[i].Name" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (e.Value?.ToString() ?? "", t.Unit, t.Qty, t.Quantity, t.Cost); })" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
<input value="@_recipeIngredients[i].Quantity" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0, t.Cost); })" type="number" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
<input value="@_recipeIngredients[i].Unit" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, e.Value?.ToString() ?? "", t.Qty, t.Quantity, t.Cost); })" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
<input value="@_recipeIngredients[i].Cost" @onchange="@(e => { var t = _recipeIngredients[i]; _recipeIngredients[i] = (t.Name, t.Unit, t.Qty, t.Quantity, decimal.TryParse(e.Value?.ToString(), out var v) ? v : 0); })" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
<button @onclick='() => _recipeIngredients.RemoveAt(i)' style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">✕</button>
</div>
}
</div>
<div style="display:flex;gap:8px;">
<button class="admin-btn-primary" @onclick="SaveRecipe" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
<button @onclick='() => _showRecipeForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_recipeFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_recipeFormSuccess ? "#22C55E" : "#EF4444");">@_recipeFormMessage</div> }
</div>
</div>
}
@if (!_recipes.Any())
{
@RenderEmpty("flask-conical", "#FF5C00", "Chưa có công thức", "Thêm công thức và nguyên liệu pha chế")
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
@foreach (var recipe in _recipes)
{
var isExpanded = _expandedRecipeId == recipe.Id;
<div class="admin-panel" style="cursor:pointer;" @onclick='() => { _expandedRecipeId = isExpanded ? null : recipe.Id; StateHasChanged(); }'>
<div class="admin-panel__body" style="padding:16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="font-weight:700;font-size:14px;">@recipe.Name</div>
<div style="display:flex;gap:4px;" @onclick:stopPropagation>
<button @onclick='() => DeleteRecipeItem(recipe.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
</div>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @recipe.PrepTimeMinutes phút chuẩn bị</div>
@if (isExpanded && !string.IsNullOrEmpty(recipe.Instructions))
{
<div style="font-size:12px;color:var(--admin-text-secondary);margin-top:8px;padding:8px;border-radius:6px;background:rgba(255,92,0,0.05);">@recipe.Instructions</div>
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
// Recipes state
private List<PosDataService.RecipeInfo> _recipes = new();
private bool _showRecipeForm;
private Guid? _editingRecipeId;
private string _newRecipeName = "";
private string _newRecipeInstructions = "";
private int _newRecipePrepTime = 5;
private List<(string Name, string Unit, string Qty, decimal Quantity, decimal Cost)> _recipeIngredients = new();
private Guid? _expandedRecipeId;
private string? _recipeFormMessage;
private bool _recipeFormSuccess;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_recipes = await DataService.GetRecipesAsync(ShopId);
}
// ═══ RECIPE CRUD ═══
private async Task SaveRecipe()
{
_recipeFormMessage = null;
if (string.IsNullOrWhiteSpace(_newRecipeName) || ShopId == Guid.Empty)
{
_recipeFormMessage = "Vui lòng nhập tên công thức."; _recipeFormSuccess = false; return;
}
try
{
var ingredients = _recipeIngredients
.Where(i => !string.IsNullOrWhiteSpace(i.Name))
.Select(i => new PosDataService.RecipeIngredientRequest(i.Name, i.Quantity, i.Unit, i.Cost))
.ToList();
var req = new PosDataService.CreateRecipeRequest(ShopId, Guid.Empty, _newRecipeName, _newRecipeInstructions, _newRecipePrepTime, ingredients);
bool ok;
if (_editingRecipeId.HasValue)
ok = await DataService.UpdateRecipeAsync(_editingRecipeId.Value, req);
else
ok = await DataService.CreateRecipeAsync(req);
_recipeFormMessage = ok ? (_editingRecipeId.HasValue ? "Đã cập nhật công thức!" : "Đã thêm công thức!") : "Lỗi khi lưu công thức.";
_recipeFormSuccess = ok;
if (ok) { _showRecipeForm = false; _editingRecipeId = null; _recipes = await DataService.GetRecipesAsync(ShopId); }
}
catch (Exception ex) { _recipeFormMessage = $"Lỗi: {ex.Message}"; _recipeFormSuccess = false; }
}
private async Task DeleteRecipeItem(Guid id)
{
try
{
await DataService.DeleteRecipeAsync(id);
_recipes = await DataService.GetRecipesAsync(ShopId);
}
catch (Exception ex) { _errorMessage = $"Không thể xóa công thức: {ex.Message}"; }
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
@if (ctaIcon != null && ctaLabel != null)
{
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};
private static string HexToRgb(string hex)
{
hex = hex.TrimStart('#');
if (hex.Length != 6) return "0,0,0";
return $"{Convert.ToInt32(hex[..2], 16)},{Convert.ToInt32(hex[2..4], 16)},{Convert.ToInt32(hex[4..], 16)}";
}
}

View File

@@ -0,0 +1,135 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:20px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="trending-up" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(_reportOrders.Sum(o => o.TotalAmount))</span><span class="admin-stat-card__label">Tổng doanh thu</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="shopping-bag" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_reportOrders.Count</span><span class="admin-stat-card__label">Tổng đơn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="banknote" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(_reportOrders.Any() ? _reportOrders.Average(o => o.TotalAmount) : 0)</span><span class="admin-stat-card__label">Giá trị TB / đơn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="package" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_reportProducts.Count</span><span class="admin-stat-card__label">Sản phẩm</span></div></div>
</div>
@* Revenue Report *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo kỳ</h3>
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
@foreach (var (label, val) in new[] { ("Ngày", "daily"), ("Tuần", "weekly"), ("Tháng", "monthly") })
{
<button @onclick="@(() => LoadRevenueReport(val))"
style="padding:5px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;
background:@(_reportPeriod == val ? "var(--admin-orange-primary)" : "transparent");
color:@(_reportPeriod == val ? "#FFF" : "var(--admin-text-tertiary)");">
@label
</button>
}
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_revenueReport.Any())
{
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="font-size:12px;color:var(--admin-text-tertiary);text-align:left;border-bottom:1px solid var(--admin-border-subtle);">
<th style="padding:10px 16px;">KỲ</th><th style="padding:10px 16px;text-align:right;">ĐƠN HÀNG</th><th style="padding:10px 16px;text-align:right;">DOANH THU</th>
</tr></thead>
<tbody>
@foreach (var r in _revenueReport)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:10px 16px;color:var(--admin-text-primary);font-weight:500;">@r.Period.ToString("dd/MM/yyyy")</td>
<td style="padding:10px 16px;text-align:right;color:var(--admin-text-secondary);">@r.OrderCount</td>
<td style="padding:10px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(r.Revenue)</td>
</tr>
}
</tbody>
</table>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Nhấn Ngày / Tuần / Tháng để tải dữ liệu doanh thu.</div>
}
</div>
</div>
@* ─── Top products from real order_items data ─── *@
@if (_topProducts.Any())
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 style="font-size:14px;font-weight:700;margin:0;">Top sản phẩm bán chạy</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">#</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên SP</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đã bán</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Doanh thu</th>
</tr></thead><tbody>
@{ var tpRank = 1; }
@foreach (var tp in _topProducts)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@(tpRank++)</td>
<td style="padding:12px 16px;font-weight:600;">@(tp.ProductName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:#3B82F6;">@tp.TotalSold</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(tp.TotalRevenue)</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@if (_reportOrders.Any())
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 style="font-size:14px;font-weight:700;margin:0;">Đơn hàng gần nhất</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mã đơn</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá trị</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tạo</th>
</tr></thead><tbody>
@foreach (var o in _reportOrders.Take(20))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-family:monospace;font-size:12px;font-weight:600;">@o.Id.ToString()[..8]</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(o.TotalAmount)</td>
<td style="padding:12px 16px;"><span class="admin-status-badge admin-status-badge--online" style="font-size:10px;"><span class="admin-status-badge__dot"></span>@(o.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM/yyyy HH:mm")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
else
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="bar-chart-2" style="width:36px;height:36px;color:#22C55E;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có dữ liệu báo cáo</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Dữ liệu sẽ hiển thị khi có đơn hàng và hoạt động kinh doanh</p>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.OrderInfo> _reportOrders = new();
private List<PosDataService.AdminProductInfo> _reportProducts = new();
private List<PosDataService.TopProductInfo> _topProducts = new();
private List<PosDataService.RevenueReportItem> _revenueReport = new();
private string _reportPeriod = "daily";
protected override async Task OnInitializedAsync()
{
var shopGuid = ShopId != Guid.Empty ? ShopId : (Guid?)null;
_reportOrders = await DataService.GetOrdersAsync(shopGuid);
_reportProducts = await DataService.GetAllProductsAsync(shopGuid);
_topProducts = await DataService.GetTopProductsAsync(shopGuid);
}
private async Task LoadRevenueReport(string period)
{
_reportPeriod = period;
_revenueReport = await DataService.GetRevenueReportAsync(period, ShopId != Guid.Empty ? ShopId : null);
}
}

View File

@@ -0,0 +1,173 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div style="display:flex;gap:8px;">
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="door-open" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count</span><span class="admin-stat-card__label">Tổng</span></div></div>
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count(r => r.IsActive)</span><span class="admin-stat-card__label">Hoạt động</span></div></div>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingResourceId = null; _newResourceName = ""; _newResourceType = "Room"; _newResourceCapacity = 1; _resourceFormMessage = null; _showResourceForm = !_showResourceForm; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm tài nguyên
</button>
</div>
@if (_showResourceForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(236,72,153,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingResourceId.HasValue ? "Chỉnh sửa" : "Thêm tài nguyên")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_newResourceName" placeholder="VD: Phòng 01" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại</label>
<select @bind="_newResourceType" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="Room">Phòng</option>
<option value="Bed">Giường</option>
<option value="Chair">Ghế</option>
<option value="Equipment">Thiết bị</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newResourceCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="@(_editingResourceId.HasValue ? SaveResource : AddResource)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingResourceId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick='() => _showResourceForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_resourceFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_resourceFormSuccess ? "#22C55E" : "#EF4444");">@_resourceFormMessage</div> }
</div>
</div>
}
@if (!_resources.Any())
{
@RenderEmpty("door-open", "#EC4899", "Chưa có tài nguyên", "Thêm phòng, giường, thiết bị cho cửa hàng")
}
else
{
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sức chứa</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:center;width:80px;"></th>
</tr></thead><tbody>
@foreach (var r in _resources)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@r.Name</td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(r.ResourceType ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@r.Capacity</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(r.IsActive ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(r.IsActive ? "Active" : "Inactive")</span></td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick='() => EditResource(r)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick='() => DeleteResourceItem(r.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
// Resources CRUD state
private List<PosDataService.ResourceInfo> _resources = new();
private bool _showResourceForm;
private Guid? _editingResourceId;
private string _newResourceName = "";
private string _newResourceType = "Room";
private int _newResourceCapacity = 1;
private string? _resourceFormMessage;
private bool _resourceFormSuccess;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_resources = await DataService.GetResourcesAsync(ShopId);
}
// ═══ RESOURCE CRUD ═══
private void EditResource(PosDataService.ResourceInfo r)
{
_editingResourceId = r.Id;
_newResourceName = r.Name;
_newResourceType = r.ResourceType ?? "Room";
_newResourceCapacity = r.Capacity;
_resourceFormMessage = null;
_showResourceForm = true;
}
private async Task AddResource()
{
_resourceFormMessage = null;
if (string.IsNullOrWhiteSpace(_newResourceName) || ShopId == Guid.Empty)
{
_resourceFormMessage = "Vui lòng nhập tên tài nguyên."; _resourceFormSuccess = false; return;
}
try
{
await DataService.CreateResourceAsync(new PosDataService.CreateResourceRequest(ShopId, _newResourceName, _newResourceType, _newResourceCapacity));
_resourceFormMessage = $"Đã thêm '{_newResourceName}' thành công!"; _resourceFormSuccess = true;
_newResourceName = ""; _newResourceType = "Room"; _newResourceCapacity = 1;
_resources = await DataService.GetResourcesAsync(ShopId);
}
catch (Exception ex) { _resourceFormMessage = $"Lỗi: {ex.Message}"; _resourceFormSuccess = false; }
}
private async Task SaveResource()
{
_resourceFormMessage = null;
if (string.IsNullOrWhiteSpace(_newResourceName) || ShopId == Guid.Empty || !_editingResourceId.HasValue)
{
_resourceFormMessage = "Vui lòng nhập tên tài nguyên."; _resourceFormSuccess = false; return;
}
try
{
await DataService.UpdateResourceAsync(_editingResourceId.Value, new PosDataService.CreateResourceRequest(ShopId, _newResourceName, _newResourceType, _newResourceCapacity));
_resourceFormMessage = $"Đã cập nhật '{_newResourceName}' thành công!"; _resourceFormSuccess = true;
_editingResourceId = null;
_resources = await DataService.GetResourcesAsync(ShopId);
}
catch (Exception ex) { _resourceFormMessage = $"Lỗi: {ex.Message}"; _resourceFormSuccess = false; }
}
private async Task DeleteResourceItem(Guid id)
{
try
{
await DataService.DeleteResourceAsync(id);
if (ShopId != Guid.Empty) _resources = await DataService.GetResourcesAsync(ShopId);
}
catch (Exception ex) { _errorMessage = $"Không thể xóa tài nguyên: {ex.Message}"; }
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
@if (ctaIcon != null && ctaLabel != null)
{
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};
private static string HexToRgb(string hex)
{
hex = hex.TrimStart('#');
if (hex.Length != 6) return "0,0,0";
return $"{Convert.ToInt32(hex[..2], 16)},{Convert.ToInt32(hex[2..4], 16)},{Convert.ToInt32(hex[4..], 16)}";
}
}

View File

@@ -0,0 +1,234 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@inject NavigationManager Nav
@if (SubSection == "schedule")
{
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div style="display:flex;gap:8px;">
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="users" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Select(s => s.StaffId).Distinct().Count()</span><span class="admin-stat-card__label">NV có lịch</span></div></div>
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="calendar" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Count</span><span class="admin-stat-card__label">Ca làm việc</span></div></div>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showScheduleForm = !_showScheduleForm; _newSchedStaffId = Guid.Empty; _newSchedDay = 1; _newSchedStart = "08:00"; _newSchedEnd = "17:00"; _schedFormMessage = null; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm ca
</button>
</div>
@if (_showScheduleForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm lịch làm việc</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nhân viên</label>
<select @bind="_newSchedStaffIdStr" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="">-- Chọn NV --</option>
@foreach (var st in _staff)
{
var stName = !string.IsNullOrWhiteSpace(st.LastName) || !string.IsNullOrWhiteSpace(st.FirstName) ? $"{st.LastName} {st.FirstName}".Trim() : (st.EmployeeCode ?? st.Id.ToString()[..8]);
<option value="@st.Id">@stName</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày</label>
<select @bind="_newSchedDay" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="1">Thứ 2</option><option value="2">Thứ 3</option><option value="3">Thứ 4</option>
<option value="4">Thứ 5</option><option value="5">Thứ 6</option><option value="6">Thứ 7</option><option value="0">Chủ nhật</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Bắt đầu</label><input type="text" @bind="_newSchedStart" placeholder="08:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Kết thúc</label><input type="text" @bind="_newSchedEnd" placeholder="17:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="AddSchedule" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
<button @onclick='() => _showScheduleForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_schedFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_schedFormSuccess ? "#22C55E" : "#EF4444");">@_schedFormMessage</div> }
</div>
</div>
}
@if (!_staffSchedules.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="calendar-clock" style="width:36px;height:36px;color:#8B5CF6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có lịch làm việc</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Thiết lập lịch ca cho nhân viên</p>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch làm việc theo tuần</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nhân viên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Vai trò</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thứ</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Bắt đầu</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Kết thúc</th>
<th style="padding:12px 16px;width:48px;"></th>
</tr></thead><tbody>
@foreach (var s in _staffSchedules.OrderBy(x => x.DayOfWeek).ThenBy(x => x.StartTime))
{
var schedStaff = _staff.FirstOrDefault(st => st.Id == s.StaffId);
var schedStaffName = schedStaff != null && (!string.IsNullOrWhiteSpace(schedStaff.LastName) || !string.IsNullOrWhiteSpace(schedStaff.FirstName)) ? $"{schedStaff.LastName} {schedStaff.FirstName}".Trim() : (s.EmployeeCode ?? s.StaffId.ToString()[..8]);
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@schedStaffName</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;text-align:center;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.DayLabel(s.DayOfWeek)</td>
<td style="padding:12px 16px;text-align:center;">@s.StartTime</td>
<td style="padding:12px 16px;text-align:center;">@s.EndTime</td>
<td style="padding:12px 16px;text-align:center;"><button @onclick='() => DeleteScheduleItem(s.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button></td>
</tr>
}
</tbody></table>
</div>
</div>
}
}
else if (SubSection == "shifts")
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;margin-bottom:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="clock" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Count</span><span class="admin-stat-card__label">Ca đã phân</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="user-check" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count</span><span class="admin-stat-card__label">Nhân viên</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="calendar" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Select(s => s.DayOfWeek).Distinct().Count()</span><span class="admin-stat-card__label">Ngày có ca</span></div></div>
</div>
@* ─── Add Schedule Form ─── *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title">Phân ca làm việc</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="@(() => { _showScheduleForm = !_showScheduleForm; _schedFormMessage = null; })"><i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm ca</button>
</div>
@if (_showScheduleForm)
{
<div style="padding:16px;border-top:1px solid var(--admin-border-subtle);">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;align-items:end;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nhân viên</label>
<select @bind="_newSchedStaffIdStr" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;">
<option value="">-- Chọn NV --</option>
@foreach (var s in _staff) { <option value="@s.Id">@(s.EmployeeCode ?? s.Id.ToString()[..8])</option> }
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày</label>
<select @bind="_newSchedDay" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;">
<option value="1">Thứ 2</option><option value="2">Thứ 3</option><option value="3">Thứ 4</option>
<option value="4">Thứ 5</option><option value="5">Thứ 6</option><option value="6">Thứ 7</option><option value="0">Chủ nhật</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ bắt đầu</label><input type="text" @bind="_newSchedStart" placeholder="08:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ kết thúc</label><input type="text" @bind="_newSchedEnd" placeholder="17:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="AddSchedule">Lưu</button>
<button style="padding:6px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);font-size:12px;cursor:pointer;" @onclick="@(() => _showScheduleForm = false)">Hủy</button>
</div>
@if (_schedFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_schedFormSuccess ? "#22C55E" : "#EF4444");">@_schedFormMessage</div> }
</div>
}
</div>
@* ─── Weekly Grid ─── *@
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch ca — Tuần</h3></div>
<div class="admin-panel__body" style="padding:0;overflow-x:auto;">
@if (!_staff.Any())
{
<div style="text-align:center;padding:40px 20px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có nhân viên. Thêm nhân viên trong mục <a href="/admin/shop/@ShopId/staff" style="color:var(--admin-orange-primary);">Nhân sự</a>.</div>
}
else
{
<table class="admin-table" style="width:100%;min-width:700px;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);white-space:nowrap;">Nhân viên</th>
@foreach (var d in new[] { "T2", "T3", "T4", "T5", "T6", "T7", "CN" })
{
<th style="padding:12px 8px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);min-width:80px;">@d</th>
}
</tr></thead><tbody>
@foreach (var emp in _staff)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;white-space:nowrap;">@(emp.EmployeeCode ?? emp.Id.ToString()[..8])</td>
@foreach (var dow in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var sched = _staffSchedules.FirstOrDefault(s => s.StaffId == emp.Id && s.DayOfWeek == dow);
if (sched != null)
{
var isMorning = sched.StartTime?.CompareTo("12:00") < 0;
var bg = isMorning ? "rgba(59,130,246,0.12)" : "rgba(168,85,247,0.12)";
var fg = isMorning ? "#3B82F6" : "#A855F7";
var label = $"{sched.StartTime?[..5]}";
<td style="padding:8px;text-align:center;">
<span title="@(sched.StartTime) - @(sched.EndTime)" style="display:inline-block;padding:2px 6px;border-radius:6px;font-size:11px;font-weight:700;background:@bg;color:@fg;">@label</span>
<button @onclick="@(() => DeleteScheduleItem(sched.Id))" style="background:none;border:none;cursor:pointer;margin-left:2px;" title="Xóa"><i data-lucide="x" style="width:10px;height:10px;color:#EF4444;"></i></button>
</td>
}
else
{
<td style="padding:8px;text-align:center;">
<span style="display:inline-block;width:36px;height:28px;line-height:28px;border-radius:6px;font-size:12px;font-weight:700;color:var(--admin-text-tertiary);">—</span>
</td>
}
}
</tr>
}
</tbody></table>
}
</div>
</div>
<div style="display:flex;gap:16px;margin-top:12px;">
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(59,130,246,0.25);"></span> Sáng (&lt;12:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:rgba(168,85,247,0.25);"></span> Chiều (≥12:00)</div>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--admin-text-tertiary);"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:transparent;border:1px solid var(--admin-border-subtle);"></span> — = Nghỉ</div>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public string SubSection { get; set; } = "schedule";
private List<PosDataService.StaffInfo> _staff = new();
private List<PosDataService.ScheduleInfo> _staffSchedules = new();
private bool _showScheduleForm;
private Guid _newSchedStaffId;
private string _newSchedStaffIdStr = "";
private int _newSchedDay = 1;
private string _newSchedStart = "08:00";
private string _newSchedEnd = "17:00";
private string? _schedFormMessage;
private bool _schedFormSuccess;
protected override async Task OnInitializedAsync()
{
_staffSchedules = await DataService.GetStaffSchedulesAsync(ShopId != Guid.Empty ? ShopId : null);
_staff = await DataService.GetStaffForShopAsync(ShopId);
}
private async Task AddSchedule()
{
_schedFormMessage = null;
if (!Guid.TryParse(_newSchedStaffIdStr, out var staffId) || ShopId == Guid.Empty)
{
_schedFormMessage = "Vui lòng nhập đúng Staff ID."; _schedFormSuccess = false; return;
}
try
{
_newSchedStaffId = staffId;
await DataService.CreateScheduleAsync(new PosDataService.CreateScheduleRequest(ShopId, _newSchedStaffId, _newSchedDay, _newSchedStart, _newSchedEnd));
_schedFormMessage = "Đã thêm lịch làm việc thành công!"; _schedFormSuccess = true;
_showScheduleForm = false;
_staffSchedules = await DataService.GetStaffSchedulesAsync(ShopId != Guid.Empty ? ShopId : null);
}
catch (Exception ex) { _schedFormMessage = $"Lỗi: {ex.Message}"; _schedFormSuccess = false; }
}
private async Task DeleteScheduleItem(Guid id)
{
try
{
await DataService.DeleteScheduleAsync(id);
_staffSchedules = await DataService.GetStaffSchedulesAsync(ShopId != Guid.Empty ? ShopId : null);
}
catch (Exception ex) { Console.Error.WriteLine($"Không thể xóa lịch làm việc: {ex.Message}"); }
}
}

View File

@@ -0,0 +1,152 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@* ─── Shop info (read-only) ─── *@
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thông tin cửa hàng</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên cửa hàng</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">@(_shopName ?? "—")</div></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngành hàng</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">@_verticalLabel</div></div>
</div>
</div>
</div>
@* ─── Opening hours + business days ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Giờ & ngày hoạt động</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ mở cửa</label>
<input type="time" value="@_settingsOpenTime" @oninput="@(e => _settingsOpenTime = e.Value?.ToString() ?? "")" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ đóng cửa</label>
<input type="time" value="@_settingsCloseTime" @oninput="@(e => _settingsCloseTime = e.Value?.ToString() ?? "")" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">Ngày kinh doanh</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
@foreach (var (day, code) in new[] { ("T2","Monday"),("T3","Tuesday"),("T4","Wednesday"),("T5","Thursday"),("T6","Friday"),("T7","Saturday"),("CN","Sunday") })
{
var isOn = _settingsOpenDays.Contains(code);
<button type="button" @onclick="@(() => ToggleDay(code))"
style="width:40px;height:40px;border-radius:10px;border:1px solid @(isOn ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");background:@(isOn ? "rgba(255,92,0,0.15)" : "var(--admin-bg-elevated)");color:@(isOn ? "var(--admin-orange-primary)" : "var(--admin-text-tertiary)");font-size:12px;font-weight:@(isOn ? "700" : "500");cursor:pointer;">
@day
</button>
}
</div>
</div>
</div>
</div>
@* ─── Features config toggles ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Tính năng cửa hàng</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
@{ void RenderToggle(string label, bool isOn, Action<bool> setter) {
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);cursor:pointer;" @onclick="@(() => { setter(!isOn); StateHasChanged(); })">
<div style="width:36px;height:20px;border-radius:10px;background:@(isOn ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");position:relative;transition:0.2s;">
<div style="width:16px;height:16px;border-radius:50%;background:white;position:absolute;top:2px;@(isOn ? "right:2px;" : "left:2px;");transition:0.2s;"></div>
</div>
<span style="font-size:13px;font-weight:500;">@label</span>
</div>;
} }
@{ RenderToggle("Quản lý tồn kho", _featHasInventory, v => _featHasInventory = v); }
@{ RenderToggle("Đặt lịch hẹn", _featHasBooking, v => _featHasBooking = v); }
@{ RenderToggle("Quản lý bàn", _featHasTables, v => _featHasTables = v); }
@{ RenderToggle("Hiển thị bếp", _featHasKitchen, v => _featHasKitchen = v); }
@{ RenderToggle("Vận chuyển", _featHasShipping, v => _featHasShipping = v); }
@{ RenderToggle("Giao hàng", _featHasDelivery, v => _featHasDelivery = v); }
</div>
</div>
</div>
@* ─── Save button ─── *@
<div style="margin-top:16px;display:flex;align-items:center;gap:12px;">
<button class="admin-btn-primary" @onclick="SaveShopSettings" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="save" style="width:16px;height:16px;"></i>Lưu thiết lập
</button>
@if (_settingsMessage != null)
{
<span style="font-size:13px;color:@(_settingsSuccess ? "#22C55E" : "#EF4444");">@_settingsMessage</span>
}
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public string? ShopName { get; set; }
[Parameter] public string? VerticalLabel { get; set; }
// Settings state
private PosDataService.ShopSettingsInfo? _shopSettings;
private string _settingsOpenTime = "";
private string _settingsCloseTime = "";
private List<string> _settingsOpenDays = new();
private bool _featHasInventory, _featHasBooking, _featHasTables;
private bool _featHasKitchen, _featHasShipping, _featHasDelivery;
private string? _settingsMessage;
private bool _settingsSuccess;
private string? _shopName => ShopName;
private string _verticalLabel => VerticalLabel ?? "";
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
await LoadShopSettings();
}
private async Task LoadShopSettings()
{
if (ShopId == Guid.Empty) return;
try
{
_shopSettings = await DataService.GetShopSettingsAsync(ShopId);
if (_shopSettings != null)
{
_settingsOpenTime = _shopSettings.OpenTime ?? "";
_settingsCloseTime = _shopSettings.CloseTime ?? "";
_settingsOpenDays = _shopSettings.OpenDays ?? new();
if (_shopSettings.Features != null)
{
_featHasInventory = _shopSettings.Features.HasInventory;
_featHasBooking = _shopSettings.Features.HasBooking;
_featHasTables = _shopSettings.Features.HasTables;
_featHasKitchen = _shopSettings.Features.HasKitchen;
_featHasShipping = _shopSettings.Features.HasShipping;
_featHasDelivery = _shopSettings.Features.HasDelivery;
}
}
}
catch { /* non-fatal */ }
}
private async Task SaveShopSettings()
{
if (ShopId == Guid.Empty) return;
_settingsMessage = null;
var features = new PosDataService.ShopFeaturesInfo
{
HasInventory = _featHasInventory, HasBooking = _featHasBooking, HasTables = _featHasTables,
HasKitchen = _featHasKitchen, HasShipping = _featHasShipping, HasDelivery = _featHasDelivery
};
var req = new PosDataService.UpdateShopSettingsRequest(
Features: features,
OpenTime: string.IsNullOrWhiteSpace(_settingsOpenTime) ? null : _settingsOpenTime,
CloseTime: string.IsNullOrWhiteSpace(_settingsCloseTime) ? null : _settingsCloseTime,
OpenDays: _settingsOpenDays.Any() ? _settingsOpenDays : null);
var ok = await DataService.UpdateShopSettingsAsync(ShopId, req);
_settingsSuccess = ok;
_settingsMessage = ok ? "Đã lưu thiết lập thành công!" : "Lỗi khi lưu thiết lập.";
StateHasChanged();
}
private void ToggleDay(string code)
{
if (_settingsOpenDays.Contains(code)) _settingsOpenDays.Remove(code);
else _settingsOpenDays.Add(code);
StateHasChanged();
}
}

View File

@@ -0,0 +1,288 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@using Microsoft.AspNetCore.Components.Forms
@inject PosDataService DataService
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_staff.Count) nhân viên</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _newStaffAddress = ""; _staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null; _showStaffForm = !_showStaffForm; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>
Thêm nhân viên
</button>
</div>
@if (_showStaffForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingStaffId.HasValue ? "Chỉnh sửa nhân viên" : "Thêm nhân viên mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Họ</label><input type="text" @bind="_newStaffLastName" placeholder="Nguyễn" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên</label><input type="text" @bind="_newStaffFirstName" placeholder="Văn A" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mã NV *</label><input type="text" @bind="_newStaffCode" placeholder="VD: NV001" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Vai trò</label>
<select @bind="_newStaffRole" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
<option value="Cashier">Thu ngân</option>
<option value="Waiter">Phục vụ</option>
<option value="Chef">Bếp</option>
<option value="Manager">Quản lý</option>
<option value="Therapist">Therapist</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">SĐT</label><input type="text" @bind="_newStaffPhone" placeholder="0912345678" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Email *</label><input type="email" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Địa chỉ</label><input type="text" @bind="_newStaffAddress" placeholder="Số nhà, đường, phường..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="margin-top:12px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">CCCD / Giấy tờ tùy thân</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
<label style="font-size:11px;color:var(--admin-text-tertiary);display:block;margin-bottom:6px;">Mặt trước</label>
@if (_staffDocFrontPreview != null)
{
<img src="@_staffDocFrontPreview" alt="CCCD trước" style="max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnStaffDocFrontSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
<label style="font-size:11px;color:var(--admin-text-tertiary);display:block;margin-bottom:6px;">Mặt sau</label>
@if (_staffDocBackPreview != null)
{
<img src="@_staffDocBackPreview" alt="CCCD sau" style="max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnStaffDocBackSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
</div>
</div>
@if (!_editingStaffId.HasValue)
{
<div style="margin-top:12px;padding:12px;border-radius:8px;background:rgba(139,92,246,0.05);border:1px solid rgba(139,92,246,0.2);">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;">
<input type="checkbox" @bind="_createStaffAccount" /> Tạo tài khoản đăng nhập (IAM)
</label>
@if (_createStaffAccount)
{
<div style="margin-top:10px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mật khẩu *</label><input type="password" @bind="_newStaffPassword" placeholder="Min 8 ký tự" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
}
</div>
}
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="@(_editingStaffId.HasValue ? SaveStaffEdit : AddStaff)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingStaffId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick="@(() => _showStaffForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (!string.IsNullOrEmpty(_staffFormMessage))
{
<div style="margin-top:8px;font-size:13px;color:@(_staffFormSuccess ? "#22C55E" : "#EF4444");">@_staffFormMessage</div>
}
</div>
</div>
}
@if (!_staff.Any() && !_showStaffForm)
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="users" style="width:36px;height:36px;color:#8B5CF6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có nhân viên</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Thêm nhân viên để quản lý cửa hàng</p>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _showStaffForm = true; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i> Thêm nhân viên
</button>
</div>
}
else if (_staff.Any())
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="user-check" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count(s => s.Status == "Active")</span><span class="admin-stat-card__label">Đang hoạt động</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="users" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count</span><span class="admin-stat-card__label">Tổng nhân viên</span></div></div>
</div>
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nhân viên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mã NV</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Vai trò</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">SĐT</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hành động</th>
</tr></thead><tbody>
@foreach (var s in _staff)
{
var staffDisplayName = !string.IsNullOrWhiteSpace(s.LastName) || !string.IsNullOrWhiteSpace(s.FirstName) ? $"{s.LastName} {s.FirstName}".Trim() : null;
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(staffDisplayName ?? s.EmployeeCode ?? s.Id.ToString()[..6])</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(s.EmployeeCode ?? "—")</td>
<td style="padding:12px 16px;">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(s.Status == "Active" ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(s.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.Phone ?? s.Email ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => EditStaff(s))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteStaffMember(s.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
// Staff data
private List<PosDataService.StaffInfo> _staff = new();
// Staff form state
private bool _showStaffForm;
private Guid? _editingStaffId;
private string _newStaffCode = "";
private string _newStaffRole = "Cashier";
private string _newStaffPhone = "";
private string _newStaffEmail = "";
private string? _staffFormMessage;
private bool _staffFormSuccess;
private bool _createStaffAccount;
private string _newStaffFirstName = "";
private string _newStaffLastName = "";
private string _newStaffPassword = "";
// Staff extended fields state
private string _newStaffAddress = "";
// Image upload state for staff docs
private IBrowserFile? _staffDocFrontFile;
private string? _staffDocFrontPreview;
private IBrowserFile? _staffDocBackFile;
private string? _staffDocBackPreview;
// Merchant ID loaded from shop data
private Guid? _merchantId;
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
{
var shop = await DataService.GetShopByIdAsync(ShopId);
if (shop != null)
_merchantId = shop.MerchantId;
_staff = await DataService.GetStaffForShopAsync(ShopId);
}
}
private async Task AddStaff()
{
_staffFormMessage = null;
if (!_merchantId.HasValue)
{
_staffFormMessage = "Không tìm thấy thông tin merchant. Vui lòng tải lại trang."; _staffFormSuccess = false; return;
}
if (string.IsNullOrWhiteSpace(_newStaffCode))
{
_staffFormMessage = "Vui lòng nhập mã nhân viên."; _staffFormSuccess = false; return;
}
try
{
var docFrontUrl = await UploadFileIfNeeded(_staffDocFrontFile);
var docBackUrl = await UploadFileIfNeeded(_staffDocBackFile);
if (_createStaffAccount)
{
if (string.IsNullOrWhiteSpace(_newStaffEmail) || string.IsNullOrWhiteSpace(_newStaffPassword))
{
_staffFormMessage = "Vui lòng nhập đầy đủ email và mật khẩu."; _staffFormSuccess = false; return;
}
var (ok, err) = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
_newStaffEmail, _newStaffPassword, _newStaffFirstName, _newStaffLastName, _newStaffRole, ShopId));
if (!ok) { _staffFormMessage = err ?? "Lỗi tạo tài khoản IAM. Kiểm tra email/mật khẩu."; _staffFormSuccess = false; return; }
_staffFormMessage = $"Đã tạo tài khoản + mời NV '{_newStaffEmail}' thành công!"; _staffFormSuccess = true;
}
else
{
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole,
_newStaffFirstName, _newStaffLastName, _newStaffAddress, null, docFrontUrl, docBackUrl));
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
}
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _newStaffAddress = ""; _createStaffAccount = false;
_staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null;
_staff = await DataService.GetStaffForShopAsync(ShopId);
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
}
private void EditStaff(PosDataService.StaffInfo s)
{
_editingStaffId = s.Id;
_newStaffCode = s.EmployeeCode ?? "";
_newStaffRole = s.Role ?? "Cashier";
_newStaffPhone = s.Phone ?? "";
_newStaffEmail = s.Email ?? "";
_newStaffFirstName = s.FirstName ?? "";
_newStaffLastName = s.LastName ?? "";
_newStaffAddress = s.Address ?? "";
_staffDocFrontFile = null; _staffDocBackFile = null;
_staffDocFrontPreview = s.DocumentFrontUrl;
_staffDocBackPreview = s.DocumentBackUrl;
_staffFormMessage = null;
_showStaffForm = true;
}
private async Task SaveStaffEdit()
{
_staffFormMessage = null;
if (string.IsNullOrWhiteSpace(_newStaffCode) || !_merchantId.HasValue || !_editingStaffId.HasValue)
{
_staffFormMessage = "Vui lòng nhập Mã NV."; _staffFormSuccess = false; return;
}
try
{
var docFrontUrl = await UploadFileIfNeeded(_staffDocFrontFile) ?? (_staffDocFrontPreview?.StartsWith("data:") == true ? null : _staffDocFrontPreview);
var docBackUrl = await UploadFileIfNeeded(_staffDocBackFile) ?? (_staffDocBackPreview?.StartsWith("data:") == true ? null : _staffDocBackPreview);
await DataService.UpdateStaffAsync(_editingStaffId.Value, new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole,
_newStaffFirstName, _newStaffLastName, _newStaffAddress, null, docFrontUrl, docBackUrl));
_staffFormMessage = $"Đã cập nhật NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
_editingStaffId = null;
_staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null;
_staff = await DataService.GetStaffForShopAsync(ShopId);
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
}
private async Task DeleteStaffMember(Guid staffId)
{
try
{
await DataService.DeleteStaffAsync(staffId);
_staff = await DataService.GetStaffForShopAsync(ShopId);
}
catch (Exception ex) { Console.Error.WriteLine($"Không thể xóa nhân viên: {ex.Message}"); }
}
private async Task OnStaffDocFrontSelected(InputFileChangeEventArgs e)
{
_staffDocFrontFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 300, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_staffDocFrontPreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task OnStaffDocBackSelected(InputFileChangeEventArgs e)
{
_staffDocBackFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 300, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_staffDocBackPreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task<string?> UploadFileIfNeeded(IBrowserFile? file)
{
if (file == null) return null;
using var stream = file.OpenReadStream(maxAllowedSize: 10_485_760);
return await DataService.UploadImageAsync(stream, file.Name, file.ContentType);
}
}

View File

@@ -0,0 +1,323 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@if (SubSection == "tables")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang dùng: @_tables.Count(t => t.Status == "occupied")</span>
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 4; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm bàn
</button>
</div>
@if (_showTableForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(245,158,11,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingTableId.HasValue ? "Chỉnh sửa bàn" : "Thêm bàn mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số bàn *</label><input type="text" @bind="_newTableNumber" placeholder="VD: 01, A1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newTableCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khu vực</label><select @bind="_newTableZone" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);"><option value="">— Chưa chọn —</option>@foreach (var z in AllZoneNames) { <option value="@z">@z</option> }</select></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="@(_editingTableId.HasValue ? SaveTable : AddTable)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingTableId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick='() => _showTableForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_tableFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_tableFormSuccess ? "#22C55E" : "#EF4444");">@_tableFormMessage</div> }
</div>
</div>
}
@if (!_tables.Any())
{
@RenderEmpty("grid-3x3", "#F59E0B", "Chưa có bàn nào", "Thêm bàn để quản lý sơ đồ phục vụ")
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
@foreach (var table in _tables)
{
var bgColor = table.Status switch { "available" => "rgba(34,197,94,0.08)", "occupied" => "rgba(239,68,68,0.08)", "reserved" => "rgba(245,158,11,0.08)", _ => "rgba(107,107,111,0.08)" };
var borderColor = table.Status switch { "available" => "rgba(34,197,94,0.3)", "occupied" => "rgba(239,68,68,0.3)", "reserved" => "rgba(245,158,11,0.3)", _ => "rgba(107,107,111,0.3)" };
var statusColor = table.Status switch { "available" => "#22C55E", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" };
var statusText = table.Status switch { "available" => "Trống", "occupied" => "Đang dùng", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => table.Status };
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;text-align:center;position:relative;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick='() => EditTable(table)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:12px;height:12px;"></i></button>
<button @onclick='() => DeleteTableItem(table.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
</div>
<div style="font-size:22px;font-weight:700;margin-bottom:4px;">@table.TableNumber</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:8px;">@(table.Zone ?? "Chung") • @table.Capacity chỗ</div>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
<span style="width:6px;height:6px;border-radius:50%;background:@statusColor;"></span>
@statusText
</div>
@if (table.SessionId.HasValue)
{
<div style="margin-top:8px;padding-top:8px;border-top:1px solid @borderColor;font-size:11px;color:var(--admin-text-tertiary);">
@table.GuestCount khách • @(table.StartedAt?.ToString("HH:mm") ?? "—")
</div>
}
</div>
}
</div>
}
}
else if (SubSection == "rooms")
{
@if (!_tables.Any())
{
@RenderEmpty("door-open", "#8B5CF6", "Chưa có phòng nào", "Thêm phòng để quản lý Karaoke", "plus-circle", "Thêm phòng")
}
else
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang hát: @_tables.Count(t => t.Status == "occupied")</span>
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@_tables.Count phòng</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
@foreach (var room in _tables)
{
var bgColor = room.Status switch { "available" => "rgba(139,92,246,0.08)", "occupied" => "rgba(239,68,68,0.08)", "reserved" => "rgba(245,158,11,0.08)", _ => "rgba(107,107,111,0.08)" };
var borderColor = room.Status switch { "available" => "rgba(139,92,246,0.3)", "occupied" => "rgba(239,68,68,0.3)", "reserved" => "rgba(245,158,11,0.3)", _ => "rgba(107,107,111,0.3)" };
var statusColor = room.Status switch { "available" => "#8B5CF6", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" };
var statusText = room.Status switch { "available" => "Trống", "occupied" => "Đang hát", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => room.Status };
var roomType = (room.Zone ?? "").ToLower() switch { var z when z.Contains("vip") => ("VIP", "#F59E0B", 200000m), var z when z.Contains("party") => ("Party", "#EC4899", 350000m), _ => ("Standard", "#8B5CF6", 120000m) };
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="door-open" style="width:20px;height:20px;color:@statusColor;"></i>
<span style="font-size:18px;font-weight:700;">Phòng @room.TableNumber</span>
</div>
<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;background:@($"{roomType.Item2}22");color:@roomType.Item2;">@roomType.Item1</span>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(room.Zone ?? "Chung") • @room.Capacity chỗ</div>
<div style="font-size:12px;font-weight:600;color:var(--admin-orange-primary);margin-bottom:8px;">@ShopHelpers.FormatVND(roomType.Item3)/giờ</div>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
<span style="width:6px;height:6px;border-radius:50%;background:@statusColor;"></span>
@statusText
</div>
@if (room.SessionId.HasValue)
{
var elapsed = DateTime.UtcNow - (room.StartedAt ?? DateTime.UtcNow);
var hours = Math.Max(1, (int)Math.Ceiling(elapsed.TotalHours));
var bill = hours * roomType.Item3;
<div style="margin-top:8px;padding-top:8px;border-top:1px solid @borderColor;">
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:4px;">@room.GuestCount khách • Bắt đầu @(room.StartedAt?.ToString("HH:mm") ?? "—")</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:11px;color:var(--admin-text-tertiary);"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @hours giờ</span>
<span style="font-size:13px;font-weight:700;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(bill)</span>
</div>
</div>
}
</div>
}
</div>
}
}
else if (SubSection == "zones")
{
var tableZones = _tables.GroupBy(t => t.Zone ?? "Chung").Select(g => new { Name = g.Key, Count = g.Count() }).ToList();
var allZones = tableZones.Select(z => z.Name).Union(_customZones).Distinct().OrderBy(z => z).ToList();
var zoneGroups = allZones.Select((z, i) => new { Name = z, Count = tableZones.FirstOrDefault(tz => tz.Name == z)?.Count ?? 0, Color = _zoneColors[i % _zoneColors.Length], Icon = _zoneIcons[i % _zoneIcons.Length] }).ToList();
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title"><i data-lucide="map-pin" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>Quản lý khu vực</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showZoneForm = !_showZoneForm; _editingZoneOriginalName = null; _newZoneName = ""; _zoneFormMessage = null; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm khu vực
</button>
</div>
<div class="admin-panel__body">
@if (_showZoneForm)
{
<div style="margin-bottom:16px;padding:16px;border-radius:10px;border:1px solid rgba(245,158,11,0.3);background:var(--admin-bg-elevated);">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">@(_editingZoneOriginalName != null ? "Đổi tên khu vực" : "Thêm khu vực mới")</div>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" @bind="_newZoneName" placeholder="VD: Sảnh chính, VIP, Sân vườn" style="flex:1;padding:8px 12px;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" style="padding:8px 16px;font-size:12px;" @onclick="SaveZone"><i data-lucide="check" style="width:14px;height:14px;margin-right:4px;"></i>Lưu</button>
<button @onclick='() => _showZoneForm = false' style="padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;font-size:12px;"><i data-lucide="x" style="width:14px;height:14px;"></i></button>
</div>
@if (_zoneFormMessage != null) { <div style="margin-top:6px;font-size:12px;color:@(_zoneFormSuccess ? "#22C55E" : "#EF4444");">@_zoneFormMessage</div> }
</div>
}
@if (!zoneGroups.Any())
{
@RenderEmpty("map-pin", "#F59E0B", "Chưa có khu vực nào", "Nhấn 'Thêm khu vực' ở trên để tạo khu vực đầu tiên", "", "", "")
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
@foreach (var zone in zoneGroups)
{
var rgbVal = zone.Color switch { "#3B82F6" => "59,130,246", "#A855F7" => "168,85,247", "#22C55E" => "34,197,94", "#F59E0B" => "245,158,11", "#EC4899" => "236,72,153", _ => "99,102,241" };
<div style="padding:20px;border-radius:12px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:40px;height:40px;border-radius:10px;background:rgba(@(rgbVal),0.15);display:flex;align-items:center;justify-content:center;">
<i data-lucide="@zone.Icon" style="width:20px;height:20px;color:@zone.Color;"></i>
</div>
<div>
<div style="font-weight:700;font-size:15px;">@zone.Name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@zone.Count bàn</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;">
<span style="font-size:11px;padding:3px 10px;border-radius:6px;background:rgba(34,197,94,0.12);color:#22C55E;">Đang hoạt động</span>
</div>
<div style="display:flex;gap:12px;border-top:1px solid var(--admin-border-subtle);padding-top:12px;">
<button @onclick='() => { _editingZoneOriginalName = zone.Name; _newZoneName = zone.Name; _showZoneForm = true; _zoneFormMessage = null; }' style="font-size:12px;color:var(--admin-orange-primary);background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:4px;"><i data-lucide="pencil" style="width:12px;height:12px;"></i>Đổi tên</button>
</div>
</div>
}
</div>
}
</div>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
[Parameter] public string SubSection { get; set; } = "tables";
// Tables state
private List<PosDataService.TableInfo> _tables = new();
// Table form state
private bool _showTableForm;
private Guid? _editingTableId;
private string _newTableNumber = "";
private int _newTableCapacity = 4;
private string _newTableZone = "";
private string? _tableFormMessage;
private bool _tableFormSuccess;
// Zone form state
private bool _showZoneForm;
private string _newZoneName = "";
private string? _editingZoneOriginalName;
private string? _zoneFormMessage;
private bool _zoneFormSuccess;
private readonly List<string> _customZones = new();
private static readonly string[] _zoneColors = { "#3B82F6", "#A855F7", "#22C55E", "#F59E0B", "#EC4899", "#6366F1" };
private static readonly string[] _zoneIcons = { "building", "crown", "trees", "wine", "coffee", "map-pin" };
private List<string> AllZoneNames => _tables.Select(t => t.Zone ?? "Chung").Distinct()
.Union(_customZones).Distinct().OrderBy(z => z).ToList();
protected override async Task OnInitializedAsync()
{
if (ShopId != Guid.Empty)
_tables = await DataService.GetTablesAsync(ShopId);
}
// ═══ TABLE CRUD ═══
private void EditTable(PosDataService.TableInfo table)
{
_editingTableId = table.Id;
_newTableNumber = table.TableNumber;
_newTableCapacity = table.Capacity;
_newTableZone = table.Zone ?? "";
_tableFormMessage = null;
_showTableForm = true;
}
private async Task AddTable()
{
_tableFormMessage = null;
if (string.IsNullOrWhiteSpace(_newTableNumber) || ShopId == Guid.Empty)
{
_tableFormMessage = "Vui lòng nhập số bàn."; _tableFormSuccess = false; return;
}
try
{
await DataService.CreateTableAsync(new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone));
_tableFormMessage = $"Đã thêm bàn '{_newTableNumber}' thành công!"; _tableFormSuccess = true;
_newTableNumber = ""; _newTableCapacity = 4; _newTableZone = "";
if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId);
}
catch (Exception ex) { _tableFormMessage = $"Lỗi: {ex.Message}"; _tableFormSuccess = false; }
}
private async Task SaveTable()
{
_tableFormMessage = null;
if (string.IsNullOrWhiteSpace(_newTableNumber) || ShopId == Guid.Empty || !_editingTableId.HasValue)
{
_tableFormMessage = "Vui lòng nhập số bàn."; _tableFormSuccess = false; return;
}
try
{
await DataService.UpdateTableAsync(_editingTableId.Value, new PosDataService.CreateTableRequest(ShopId, _newTableNumber, _newTableCapacity, _newTableZone));
_tableFormMessage = $"Đã cập nhật bàn '{_newTableNumber}' thành công!"; _tableFormSuccess = true;
_editingTableId = null;
if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId);
}
catch (Exception ex) { _tableFormMessage = $"Lỗi: {ex.Message}"; _tableFormSuccess = false; }
}
private async Task DeleteTableItem(Guid id)
{
try
{
await DataService.DeleteTableAsync(id);
if (ShopId != Guid.Empty) _tables = await DataService.GetTablesAsync(ShopId);
}
catch (Exception ex) { Console.Error.WriteLine($"Không thể xóa bàn: {ex.Message}"); }
}
// ═══ ZONE MANAGEMENT ═══
private async Task SaveZone()
{
_zoneFormMessage = null;
if (string.IsNullOrWhiteSpace(_newZoneName) || ShopId == Guid.Empty)
{
_zoneFormMessage = "Vui lòng nhập tên khu vực."; _zoneFormSuccess = false; return;
}
if (_editingZoneOriginalName != null)
{
// Rename: update all tables in old zone to new zone name
var tablesInZone = _tables.Where(t => (t.Zone ?? "Chung") == _editingZoneOriginalName).ToList();
try
{
foreach (var t in tablesInZone)
await DataService.UpdateTableAsync(t.Id, new PosDataService.CreateTableRequest(ShopId, t.TableNumber, t.Capacity, _newZoneName.Trim()));
_tables = await DataService.GetTablesAsync(ShopId);
_zoneFormMessage = $"Đã đổi tên '{_editingZoneOriginalName}' → '{_newZoneName.Trim()}'"; _zoneFormSuccess = true;
_editingZoneOriginalName = null; _newZoneName = ""; _showZoneForm = false;
}
catch (Exception ex) { _zoneFormMessage = $"Lỗi: {ex.Message}"; _zoneFormSuccess = false; }
}
else
{
var zoneName = _newZoneName.Trim();
if (!_customZones.Contains(zoneName) && !_tables.Any(t => (t.Zone ?? "Chung") == zoneName))
_customZones.Add(zoneName);
_zoneFormMessage = $"Đã thêm khu vực '{zoneName}'."; _zoneFormSuccess = true;
_newTableZone = zoneName;
_newZoneName = "";
_showZoneForm = false;
}
}
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({ShopHelpers.HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
@if (ctaIcon != null && ctaLabel != null)
{
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};
}