feat(web-client-tpos): Phase B — campaigns CRUD, customer CRUD, fixed promotions query
BFF Endpoints (6 new): - POST/PUT/DELETE campaigns — CRUD with merchant ownership validation - POST/PUT/DELETE members — customer CRUD with soft-delete - Fixed GetPromotions: promotions → campaigns table PosDataService (6 new methods): - CreateCampaign, UpdateCampaign, DeleteCampaign - CreateMember, UpdateMember, DeleteMember ShopPage UI (191 lines): - Promotions tab: campaign table with add/edit/delete + form - Customers tab: add/edit/delete buttons on member rows
This commit is contained in:
@@ -542,12 +542,40 @@
|
||||
case "customers":
|
||||
<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="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 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"; }'>
|
||||
<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;">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>
|
||||
<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; }'>Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
var filteredMembers = string.IsNullOrWhiteSpace(_customerSearch)
|
||||
? _members
|
||||
: _members.Where(m => m.Id.ToString().Contains(_customerSearch, StringComparison.OrdinalIgnoreCase)
|
||||
@@ -635,6 +663,12 @@
|
||||
<button style="padding:6px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
|
||||
<i data-lucide="history" style="width:12px;height:12px;"></i> Lịch sử đơn
|
||||
</button>
|
||||
<button @onclick="@(() => EditMember(m))" style="padding:6px 14px;border-radius:8px;border:none;background:rgba(59,130,246,0.1);color:#3B82F6;font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
|
||||
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Sửa
|
||||
</button>
|
||||
<button @onclick="@(() => DeleteMemberItem(m.Id))" 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>
|
||||
@@ -1156,38 +1190,75 @@
|
||||
}
|
||||
break;
|
||||
|
||||
// ═══ PROMOTIONS (real data) ═══
|
||||
// ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══
|
||||
case "promotions":
|
||||
@if (!_promotions.Any())
|
||||
<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; _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)
|
||||
{
|
||||
@RenderEmpty("tag", "#22C55E", "Chưa có khuyến mãi", "Tạo mã giảm giá, combo, chương trình loyalty", "plus-circle", "Tạo khuyến mãi")
|
||||
<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;">Giá trị voucher (₫) *</label><input type="number" @bind="_newCampaignValue" 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><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())
|
||||
{
|
||||
@RenderEmpty("tag", "#22C55E", "Chưa có chiến dịch", "Tạo chiến dịch voucher, khuyến mãi cho khách hàng", "plus-circle", "Thêm chiến dịch")
|
||||
}
|
||||
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">@_promotions.Count</span><span class="admin-stat-card__label">Tổng KM</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">@_promotions.Count(p => p.IsActive)</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">@_promotions.Sum(p => p.VoucherCount)</span><span class="admin-stat-card__label">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">@_promotions.Sum(p => p.RedemptionCount)</span><span class="admin-stat-card__label">Đã dùng</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="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.StatusId == 1)</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 khuyến mãi</h3></div>
|
||||
<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);">Trạng thái</th>
|
||||
<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: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 p in _promotions)
|
||||
@foreach (var c in _campaigns)
|
||||
{
|
||||
<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;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(p.DiscountType ?? "—")</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@(p.DiscountType == "Percentage" ? $"{p.DiscountValue}%" : FormatVND(p.DiscountValue ?? 0))</td>
|
||||
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(p.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>@(p.IsActive ? "Active" : "Inactive")</span></td>
|
||||
<td style="padding:12px 16px;font-size:12px;color:var(--admin-text-tertiary);">@(p.StartDate?.ToString("dd/MM/yy") ?? "—") → @(p.EndDate?.ToString("dd/MM/yy") ?? "∞")</td>
|
||||
<td style="padding:12px 16px;font-weight:600;">@c.Name</td>
|
||||
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@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>
|
||||
@@ -1701,11 +1772,28 @@
|
||||
private string? _staffFormMessage;
|
||||
private bool _staffFormSuccess;
|
||||
private Guid? _merchantId;
|
||||
// New data: wallets, promotions, member levels, schedules, inv txns
|
||||
// New data: wallets, promotions, campaigns, member levels, schedules, inv txns
|
||||
private List<PosDataService.WalletInfo> _wallets = new();
|
||||
private List<PosDataService.WalletTxnInfo> _walletTxns = new();
|
||||
private List<PosDataService.PromotionInfo> _promotions = new();
|
||||
private List<PosDataService.CampaignInfo> _campaigns = new();
|
||||
private List<PosDataService.LevelDefinitionInfo> _memberLevels = new();
|
||||
// Campaign form state
|
||||
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? _campaignFormMessage;
|
||||
private bool _campaignFormSuccess;
|
||||
// Member form state
|
||||
private bool _showMemberForm;
|
||||
private Guid? _editingMemberId;
|
||||
private string _newMemberGender = "";
|
||||
private string _newMemberCountry = "VN";
|
||||
private List<PosDataService.ScheduleInfo> _staffSchedules = new();
|
||||
private List<PosDataService.InventoryTxnInfo> _invTxns = new();
|
||||
// P2 state: calendar, KDS, treatments
|
||||
@@ -1833,7 +1921,7 @@
|
||||
_reportProducts = await DataService.GetAllProductsAsync(_shopGuid);
|
||||
break;
|
||||
case "promotions":
|
||||
_promotions = await DataService.GetPromotionsAsync();
|
||||
_campaigns = await DataService.GetCampaignsAsync();
|
||||
break;
|
||||
case "schedule":
|
||||
_staffSchedules = await DataService.GetStaffSchedulesAsync(_shopGuid);
|
||||
@@ -2084,4 +2172,61 @@
|
||||
|
||||
// ═══ REVENUE REPORT ═══
|
||||
private async Task LoadRevenueReport(string period) { _reportPeriod = period; _revenueReport = await DataService.GetRevenueReportAsync(period, _shopGuid); }
|
||||
|
||||
// ═══ CAMPAIGN CRUD ═══
|
||||
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 req = new PosDataService.CreateCampaignRequest(_newCampaignName, _newCampaignDesc, _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();
|
||||
}
|
||||
|
||||
// ═══ MEMBER CRUD ═══
|
||||
private async Task SaveMember()
|
||||
{
|
||||
bool ok;
|
||||
if (_editingMemberId.HasValue)
|
||||
ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
|
||||
else
|
||||
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry));
|
||||
if (ok) { _showMemberForm = false; _editingMemberId = null; _members = await DataService.GetMembersAsync(); }
|
||||
}
|
||||
|
||||
private void EditMember(PosDataService.MemberInfo m)
|
||||
{
|
||||
_editingMemberId = m.Id; _newMemberGender = m.Gender ?? ""; _newMemberCountry = m.CountryCode ?? "VN";
|
||||
_showMemberForm = true;
|
||||
}
|
||||
|
||||
private async Task DeleteMemberItem(Guid memberId)
|
||||
{
|
||||
await DataService.DeleteMemberAsync(memberId);
|
||||
_members = await DataService.GetMembersAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,66 @@ public class PosDataService
|
||||
public async Task<List<PromotionInfo>> GetPromotionsAsync()
|
||||
{ AttachToken(); return await _http.GetFromJsonAsync<List<PromotionInfo>>("api/bff/promotions", _jsonOptions) ?? new(); }
|
||||
|
||||
// ═══ CAMPAIGNS CRUD ═══
|
||||
|
||||
// EN: Campaign record and request DTOs for CRUD operations
|
||||
// VI: Record chiến dịch và DTO yêu cầu cho CRUD
|
||||
public record CampaignInfo(Guid Id, string Name, string? Description, decimal FaceValue,
|
||||
int TotalVouchers, int IssuedVouchers, DateTime? StartDate, DateTime? EndDate, int StatusId, DateTime CreatedAt);
|
||||
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
|
||||
|
||||
public async Task<List<CampaignInfo>> GetCampaignsAsync()
|
||||
{ AttachToken(); return await _http.GetFromJsonAsync<List<CampaignInfo>>("api/bff/promotions", _jsonOptions) ?? new(); }
|
||||
|
||||
public async Task<bool> CreateCampaignAsync(CreateCampaignRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _jsonOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _jsonOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCampaignAsync(Guid campaignId)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.DeleteAsync($"api/bff/campaigns/{campaignId}");
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
// ═══ MEMBER CRUD ═══
|
||||
|
||||
// EN: Member create/update request DTOs
|
||||
// VI: DTO tạo/cập nhật thành viên
|
||||
public record CreateMemberRequest(string? Gender, string? CountryCode);
|
||||
public record UpdateMemberRequest(string? Gender, string? Preferences);
|
||||
|
||||
public async Task<bool> CreateMemberAsync(CreateMemberRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _jsonOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateMemberAsync(Guid memberId, UpdateMemberRequest req)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _jsonOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMemberAsync(Guid memberId)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.DeleteAsync($"api/bff/members/{memberId}");
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
// ═══ INVENTORY TRANSACTIONS ═══
|
||||
|
||||
public record InventoryTxnInfo(Guid Id, Guid InventoryItemId, int QuantityChange, string? Reason, DateTime CreatedAt, string? TransactionType);
|
||||
|
||||
@@ -701,23 +701,149 @@ public class BffDataController : ControllerBase
|
||||
return Ok(devices);
|
||||
}
|
||||
|
||||
// ═══ PROMOTIONS ═══
|
||||
// ═══ PROMOTIONS / CAMPAIGNS ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaigns for current merchant — scoped by merchant_id.
|
||||
/// VI: Lấy danh sách chiến dịch của merchant hiện tại — lọc theo merchant_id.
|
||||
/// </summary>
|
||||
[HttpGet("promotions")]
|
||||
public async Task<IActionResult> GetPromotions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var merchantId = await GetCurrentMerchantIdAsync();
|
||||
if (merchantId == null) return Ok(Array.Empty<object>());
|
||||
|
||||
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
|
||||
var promos = await conn.QueryAsync<dynamic>(
|
||||
@"SELECT c.id, c.name, c.description, c.start_date, c.end_date, c.is_active, c.discount_type, c.discount_value,
|
||||
(SELECT COUNT(*) FROM vouchers v WHERE v.campaign_id = c.id) as voucher_count,
|
||||
(SELECT COUNT(*) FROM redemptions r WHERE r.campaign_id = c.id) as redemption_count
|
||||
FROM campaigns c ORDER BY c.created_at DESC");
|
||||
return Ok(promos);
|
||||
var campaigns = await conn.QueryAsync<dynamic>(
|
||||
@"SELECT id, name, description, face_value, total_vouchers, issued_vouchers,
|
||||
start_date, end_date, status_id, created_at
|
||||
FROM campaigns
|
||||
WHERE merchant_id = @MerchantId
|
||||
ORDER BY created_at DESC",
|
||||
new { MerchantId = merchantId });
|
||||
return Ok(campaigns);
|
||||
}
|
||||
catch { return Ok(Array.Empty<object>()); }
|
||||
}
|
||||
|
||||
// ═══ CAMPAIGNS CRUD ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a campaign — validates merchant ownership.
|
||||
/// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant.
|
||||
/// </summary>
|
||||
[HttpPost("campaigns")]
|
||||
public async Task<IActionResult> CreateCampaign([FromBody] CreateCampaignRequest req)
|
||||
{
|
||||
var merchantId = await GetCurrentMerchantIdAsync();
|
||||
if (merchantId == null) return Unauthorized();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
|
||||
await conn.ExecuteAsync(
|
||||
@"INSERT INTO campaigns (id, merchant_id, name, description, face_value, total_vouchers, issued_vouchers,
|
||||
start_date, end_date, status_id, created_at, updated_at,
|
||||
backing_asset_type_id, backing_asset_code, acquisition_type_id, acquisition_price,
|
||||
escrow_amount, max_per_user, voucher_validity_days)
|
||||
VALUES (@Id, @MerchantId, @Name, @Description, @FaceValue, @TotalVouchers, 0,
|
||||
@StartDate, @EndDate, 1, @Now, @Now,
|
||||
1, 'VND', 1, 0, 0, 1, 30)",
|
||||
new { Id = id, MerchantId = merchantId, req.Name, req.Description, req.FaceValue,
|
||||
req.TotalVouchers, req.StartDate, req.EndDate, Now = now });
|
||||
return StatusCode(201, new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a campaign — validates merchant ownership.
|
||||
/// VI: Cập nhật chiến dịch — kiểm tra quyền sở hữu merchant.
|
||||
/// </summary>
|
||||
[HttpPut("campaigns/{campaignId:guid}")]
|
||||
public async Task<IActionResult> UpdateCampaign(Guid campaignId, [FromBody] CreateCampaignRequest req)
|
||||
{
|
||||
var merchantId = await GetCurrentMerchantIdAsync();
|
||||
if (merchantId == null) return Unauthorized();
|
||||
|
||||
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
|
||||
var rows = await conn.ExecuteAsync(
|
||||
@"UPDATE campaigns SET name=@Name, description=@Description, face_value=@FaceValue,
|
||||
total_vouchers=@TotalVouchers, start_date=@StartDate, end_date=@EndDate,
|
||||
updated_at=NOW()
|
||||
WHERE id=@Id AND merchant_id=@MerchantId",
|
||||
new { Id = campaignId, MerchantId = merchantId, req.Name, req.Description,
|
||||
req.FaceValue, req.TotalVouchers, req.StartDate, req.EndDate });
|
||||
return rows > 0 ? Ok(new { id = campaignId }) : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Disable a campaign (soft-delete by status_id=0) — validates merchant ownership.
|
||||
/// VI: Vô hiệu hóa chiến dịch (soft-delete bằng status_id=0) — kiểm tra quyền sở hữu merchant.
|
||||
/// </summary>
|
||||
[HttpDelete("campaigns/{campaignId:guid}")]
|
||||
public async Task<IActionResult> DeleteCampaign(Guid campaignId)
|
||||
{
|
||||
var merchantId = await GetCurrentMerchantIdAsync();
|
||||
if (merchantId == null) return Unauthorized();
|
||||
|
||||
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE campaigns SET status_id=0, updated_at=NOW()
|
||||
WHERE id=@Id AND merchant_id=@MerchantId",
|
||||
new { Id = campaignId, MerchantId = merchantId });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══ MEMBERS CRUD ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a member — inserts with sensible defaults.
|
||||
/// VI: Tạo thành viên — thêm với giá trị mặc định hợp lý.
|
||||
/// </summary>
|
||||
[HttpPost("members")]
|
||||
public async Task<IActionResult> CreateMember([FromBody] CreateMemberRequest req)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
|
||||
await conn.ExecuteAsync(
|
||||
@"INSERT INTO members (id, country_code, current_exp, current_level, gender,
|
||||
is_deleted, total_exp_earned, created_at, updated_at)
|
||||
VALUES (@Id, @CountryCode, 0, 1, @Gender, false, 0, @Now, @Now)",
|
||||
new { Id = id, CountryCode = req.CountryCode ?? "VN", req.Gender, Now = now });
|
||||
return StatusCode(201, new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a member's gender and preferences.
|
||||
/// VI: Cập nhật giới tính và tùy chọn cá nhân của thành viên.
|
||||
/// </summary>
|
||||
[HttpPut("members/{memberId:guid}")]
|
||||
public async Task<IActionResult> UpdateMember(Guid memberId, [FromBody] UpdateMemberRequest req)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
|
||||
var rows = await conn.ExecuteAsync(
|
||||
@"UPDATE members SET gender=@Gender, preferences=@Preferences::jsonb, updated_at=NOW()
|
||||
WHERE id=@Id AND is_deleted=false",
|
||||
new { Id = memberId, req.Gender, Preferences = req.Preferences ?? "{}" });
|
||||
return rows > 0 ? Ok(new { id = memberId }) : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft-delete a member.
|
||||
/// VI: Xóa mềm thành viên.
|
||||
/// </summary>
|
||||
[HttpDelete("members/{memberId:guid}")]
|
||||
public async Task<IActionResult> DeleteMember(Guid memberId)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE members SET is_deleted=true, updated_at=NOW() WHERE id=@Id",
|
||||
new { Id = memberId });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══ INVENTORY TRANSACTIONS ═══
|
||||
[HttpGet("inventory/transactions")]
|
||||
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
|
||||
@@ -1288,4 +1414,7 @@ public class BffDataController : ControllerBase
|
||||
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
|
||||
public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder);
|
||||
public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays);
|
||||
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
|
||||
public record CreateMemberRequest(string? Gender, string? CountryCode);
|
||||
public record UpdateMemberRequest(string? Gender, string? Preferences);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user