From 96301831f11391dbfe04f41f57efb98ae2ddd577 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 3 Mar 2026 21:30:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(web-client-tpos):=20Phase=20B=20=E2=80=94?= =?UTF-8?q?=20campaigns=20CRUD,=20customer=20CRUD,=20fixed=20promotions=20?= =?UTF-8?q?query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Pages/Admin/Shop/ShopPage.razor | 191 +++++++++++++++--- .../Services/PosDataService.cs | 60 ++++++ .../Controllers/BffDataController.cs | 143 ++++++++++++- 3 files changed, 364 insertions(+), 30 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index 5d63f83a..a8af198a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -542,12 +542,40 @@ case "customers":

@_members.Count khách hàng

-
- - +
+
+ + +
+
+ @if (_showMemberForm) + { +
+

@(_editingMemberId.HasValue ? "Sửa khách hàng" : "Thêm khách hàng")

+
+
+
+ +
+
+
+
+ + +
+
+
+ } var filteredMembers = string.IsNullOrWhiteSpace(_customerSearch) ? _members : _members.Where(m => m.Id.ToString().Contains(_customerSearch, StringComparison.OrdinalIgnoreCase) @@ -635,6 +663,12 @@ + +
@@ -1156,38 +1190,75 @@ } break; - // ═══ PROMOTIONS (real data) ═══ + // ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══ case "promotions": - @if (!_promotions.Any()) +
+

@_campaigns.Count chiến dịch

+ +
+ @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") +
+

@(_editingCampaignId.HasValue ? "Sửa chiến dịch" : "Thêm chiến dịch")

+
+
+
+
+
+
+
+
+
+ @if (_campaignFormMessage != null) + { +
@_campaignFormMessage
+ } +
+ + +
+
+
+ } + @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 {
-
@_promotions.CountTổng KM
-
@_promotions.Count(p => p.IsActive)Đang hoạt động
-
@_promotions.Sum(p => p.VoucherCount)Voucher
-
@_promotions.Sum(p => p.RedemptionCount)Đã dùng
+
@_campaigns.CountTổng chiến dịch
+
@_campaigns.Count(c => c.StatusId == 1)Đang hoạt động
+
@_campaigns.Sum(c => c.TotalVouchers)Tổng voucher
+
@_campaigns.Sum(c => c.IssuedVouchers)Đã phát
-

Danh sách khuyến mãi

+

Danh sách chiến dịch

- - - + + + + - @foreach (var p in _promotions) + @foreach (var c in _campaigns) { - - - - - + + + + + + }
TênLoại Giá trịTrạng tháiThời gianĐã phát/TổngBắt đầuKết thúc
@p.Name@(p.DiscountType ?? "—")@(p.DiscountType == "Percentage" ? $"{p.DiscountValue}%" : FormatVND(p.DiscountValue ?? 0))@(p.IsActive ? "Active" : "Inactive")@(p.StartDate?.ToString("dd/MM/yy") ?? "—") → @(p.EndDate?.ToString("dd/MM/yy") ?? "∞")@c.Name@FormatVND(c.FaceValue)@c.IssuedVouchers / @c.TotalVouchers@(c.StartDate?.ToString("dd/MM/yy") ?? "—")@(c.EndDate?.ToString("dd/MM/yy") ?? "—") +
+ + +
+
@@ -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 _wallets = new(); private List _walletTxns = new(); private List _promotions = new(); + private List _campaigns = new(); private List _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 _staffSchedules = new(); private List _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(); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 8fcd602b..2a49f173 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -221,6 +221,66 @@ public class PosDataService public async Task> GetPromotionsAsync() { AttachToken(); return await _http.GetFromJsonAsync>("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> GetCampaignsAsync() + { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/promotions", _jsonOptions) ?? new(); } + + public async Task CreateCampaignAsync(CreateCampaignRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task 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 CreateMemberAsync(CreateMemberRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/members", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task UpdateMemberAsync(Guid memberId, UpdateMemberRequest req) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task 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); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs index fa1929d1..ee8aa645 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs @@ -701,23 +701,149 @@ public class BffDataController : ControllerBase return Ok(devices); } - // ═══ PROMOTIONS ═══ + // ═══ PROMOTIONS / CAMPAIGNS ═══ + + /// + /// 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. + /// [HttpGet("promotions")] public async Task GetPromotions() { try { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); - var promos = await conn.QueryAsync( - @"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( + @"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()); } } + // ═══ CAMPAIGNS CRUD ═══ + + /// + /// EN: Create a campaign — validates merchant ownership. + /// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant. + /// + [HttpPost("campaigns")] + public async Task 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 }); + } + + /// + /// 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. + /// + [HttpPut("campaigns/{campaignId:guid}")] + public async Task 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(); + } + + /// + /// 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. + /// + [HttpDelete("campaigns/{campaignId:guid}")] + public async Task 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 ═══ + + /// + /// 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ý. + /// + [HttpPost("members")] + public async Task 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 }); + } + + /// + /// 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. + /// + [HttpPut("members/{memberId:guid}")] + public async Task 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(); + } + + /// + /// EN: Soft-delete a member. + /// VI: Xóa mềm thành viên. + /// + [HttpDelete("members/{memberId:guid}")] + public async Task 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 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); }