Tên
- Loại
Giá trị
- Trạng thái
- Thời gian
+ Đã phát/Tổng
+ Bắt đầu
+ Kết thúc
+
- @foreach (var p in _promotions)
+ @foreach (var c in _campaigns)
{
- @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") ?? "—")
+
+
+ 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">
+ 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">
+
+
}
@@ -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);
}