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:
Ho Ngoc Hai
2026-03-03 21:30:27 +07:00
parent 14d6c4012c
commit 96301831f1
3 changed files with 364 additions and 30 deletions

View File

@@ -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();
}
}

View File

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

View File

@@ -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);
}