From ca1aacfc4c3fe4a6d875d5deba13ec468bd3bcae Mon Sep 17 00:00:00 2001 From: MingoUwU Date: Sat, 15 Aug 2026 12:50:29 +0700 Subject: [PATCH 1/6] Translate status labels to Vietnamese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace English status strings in the profile view with Vietnamese equivalents for better localization. In frontend/lib/features/profile/views/profile_view.dart, 'ACTIVE' was changed to 'HOẠT ĐỘNG' and 'FREE' to 'MIỄN PHÍ'. 'OFFICE' remains unchanged. --- frontend/lib/features/profile/views/profile_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/features/profile/views/profile_view.dart b/frontend/lib/features/profile/views/profile_view.dart index c4daf6d..8144b1a 100644 --- a/frontend/lib/features/profile/views/profile_view.dart +++ b/frontend/lib/features/profile/views/profile_view.dart @@ -834,7 +834,7 @@ class _ProfileViewState extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - _officeModeActivated ? 'OFFICE' : (isPro ? 'ACTIVE' : 'FREE'), + _officeModeActivated ? 'OFFICE' : (isPro ? 'HOẠT ĐỘNG' : 'MIỄN PHÍ'), style: const TextStyle( color: Colors.white, fontSize: 10, From 6c4fdf852828d0b5589f54861baa406ef3199bdf Mon Sep 17 00:00:00 2001 From: MingoUwU Date: Sat, 15 Aug 2026 14:01:51 +0700 Subject: [PATCH 2/6] update vietnamese --- frontend/lib/features/profile/views/profile_view.dart | 6 +++++- .../features/subscription/views/upgrade_plan_screen.dart | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/lib/features/profile/views/profile_view.dart b/frontend/lib/features/profile/views/profile_view.dart index 8144b1a..b4de0f3 100644 --- a/frontend/lib/features/profile/views/profile_view.dart +++ b/frontend/lib/features/profile/views/profile_view.dart @@ -834,7 +834,11 @@ class _ProfileViewState extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - _officeModeActivated ? 'OFFICE' : (isPro ? 'HOẠT ĐỘNG' : 'MIỄN PHÍ'), + _officeModeActivated + ? 'OFFICE' + : (isPro + ? activeSubscription!.status.translatedData.toUpperCase() + : 'MIỄN PHÍ'), style: const TextStyle( color: Colors.white, fontSize: 10, diff --git a/frontend/lib/features/subscription/views/upgrade_plan_screen.dart b/frontend/lib/features/subscription/views/upgrade_plan_screen.dart index d35a4f4..526c37e 100644 --- a/frontend/lib/features/subscription/views/upgrade_plan_screen.dart +++ b/frontend/lib/features/subscription/views/upgrade_plan_screen.dart @@ -482,7 +482,7 @@ class _UpgradePlanScreenState extends State { const SizedBox(height: 4), if (isBaselineFree) const Text( - 'Trạng thái: Active • Không giới hạn', + 'Trạng thái: Đang hoạt động • Không giới hạn', style: TextStyle(color: AppColors.textSecondary, fontSize: 13), ) else ...[ From 5200e690d443b43764b03d396e3fb11a3ba8a6f6 Mon Sep 17 00:00:00 2001 From: Duy163 Date: Sat, 15 Aug 2026 15:48:11 +0700 Subject: [PATCH 3/6] refactor: Update authorization policies and roles for Gym features - Changed the authorization policy from `GymerOnly` to `GymFeatures` across multiple controllers including `GymGoalsController`, `MealPlanProposalController`, and `PtReviewController`. - Updated the `AssignRoleRequest` to restrict roles to `User`, `Coach`, and `Admin` using a regular expression. - Modified the `NutritionTrackingService` to utilize the new entitlement-based access check for gym features. - Adjusted database scripts to normalize legacy roles and ensure proper role assignments. - Enhanced documentation to reflect changes in role management and entitlement policies. --- .../Controllers/GymGoalsController.cs | 2 +- .../Controllers/MealPlanProposalController.cs | 6 +- .../Controllers/PtReviewController.cs | 2 +- backend/MenuGreen.API/Program.cs | 7 +- .../DTOs/Requests/AssignRoleRequest.cs | 3 +- .../Services/NutritionTrackingService.cs | 17 +- .../Services/UserService.cs | 26 +- backend/database/01_roles.sql | 7 +- backend/database/02_users.sql | 8 +- backend/database/README.md | 47 + docs/02-backend/README_BACKEND_OVERVIEW.md | 2 +- .../backend_free_feature_access_activity.md | 8 +- docs/07-issues/issues.md | 2 +- docs/workflow/user_api_contract_user.md | 2 +- frontend-web/components/ui/badge.tsx | 4 +- .../users/components/assign-role-dialog.tsx | 8 +- .../users/components/user-management.tsx | 1 + .../i18n/api_message_translator_fixed.dart | 994 ++++++++++++++++++ .../lib/core/middleware/error_translator.dart | 2 +- .../core/services/notification_handler.dart | 4 +- .../repositories/ai_assistant_repository.dart | 2 +- .../coach/views/coach_application_screen.dart | 6 +- .../coach_application_status_screen.dart | 1 + .../coach/views/coach_main_screen.dart | 13 +- .../views/coach_profile_edit_screen.dart | 161 ++- .../coach_chat/views/coach_chat_screen.dart | 28 +- .../views/coach_create_meal_plan_screen.dart | 10 +- .../views/coach_meal_plan_detail_screen.dart | 16 +- .../views/coach_meal_plan_history_screen.dart | 1 + .../views/coach_report_detail_screen.dart | 35 +- .../discover/views/discover_view.dart | 2 +- .../discover/views/favorites_screen.dart | 2 +- .../discover/views/food_detail_screen.dart | 2 +- .../views/ingredient_detail_screen.dart | 2 +- .../views/personal_program_detail_screen.dart | 8 +- .../gymer/views/premium_programs_screen.dart | 24 +- .../views/route_approval_detail_screen.dart | 11 +- .../gymer/widgets/route_approval_card.dart | 2 +- .../home/widgets/weight_log_sheet.dart | 2 +- .../providers/meal_plan_provider.dart | 2 +- .../repositories/meal_plan_repository.dart | 4 +- .../views/meal_plan_detail_screen.dart | 2 +- .../meal_plan/views/meal_plan_screen.dart | 13 +- .../views/meal_plan_today_screen.dart | 2 +- .../models/notification_models.dart | 2 +- .../profile/views/change_password_screen.dart | 2 +- .../features/profile/views/profile_view.dart | 3 +- .../user_subscription_repository.dart | 2 +- .../views/upgrade_plan_screen.dart | 2 +- .../utils/nutrition_warning_utils.dart | 2 +- .../widgets/suggested_dish_detail_sheet.dart | 2 +- .../vietnam_local_repositories.dart | 2 +- .../views/food_capture_screen.dart | 2 +- .../vietnam_local/views/gym_goals_screen.dart | 2 +- .../views/ingredient_substitution_screen.dart | 2 +- .../views/report_issue_screen.dart | 2 +- .../views/safety_hub_screen.dart | 2 +- .../test/api_message_translator_test.dart | 27 +- .../notification_route_navigation_test.dart | 36 + 59 files changed, 1412 insertions(+), 181 deletions(-) create mode 100644 frontend/lib/core/i18n/api_message_translator_fixed.dart diff --git a/backend/MenuGreen.API/Controllers/GymGoalsController.cs b/backend/MenuGreen.API/Controllers/GymGoalsController.cs index fca053c..9da3b14 100644 --- a/backend/MenuGreen.API/Controllers/GymGoalsController.cs +++ b/backend/MenuGreen.API/Controllers/GymGoalsController.cs @@ -17,7 +17,7 @@ namespace MenuGreen.API.Controllers [ApiController] [Route("api/[controller]")] [Authorize] - [Authorize(Policy = "GymerOnly")] + [Authorize(Policy = "GymFeatures")] public class GymGoalsController : ControllerBase { private readonly IUserAiProfileService _userAiProfileService; diff --git a/backend/MenuGreen.API/Controllers/MealPlanProposalController.cs b/backend/MenuGreen.API/Controllers/MealPlanProposalController.cs index ce1590f..d5e9595 100644 --- a/backend/MenuGreen.API/Controllers/MealPlanProposalController.cs +++ b/backend/MenuGreen.API/Controllers/MealPlanProposalController.cs @@ -51,17 +51,17 @@ public async Task Get(Guid proposalId) => await ExecuteAsync(userId => _service.GetAsync(userId, proposalId)); [HttpGet("mine")] - [Authorize(Policy = "GymerOnly")] + [Authorize(Policy = "GymFeatures")] public async Task GetMine([FromQuery] string? status) => await ExecuteAsync(userId => _service.GetMineAsync(userId, status)); [HttpPost("{proposalId:guid}/apply")] - [Authorize(Policy = "GymerOnly")] + [Authorize(Policy = "GymFeatures")] public async Task Apply(Guid proposalId) => await ExecuteAsync(userId => _service.ApplyAsync(userId, proposalId)); [HttpPost("{proposalId:guid}/reject")] - [Authorize(Policy = "GymerOnly")] + [Authorize(Policy = "GymFeatures")] public async Task Reject(Guid proposalId) => await ExecuteAsync(userId => _service.RejectAsync(userId, proposalId)); diff --git a/backend/MenuGreen.API/Controllers/PtReviewController.cs b/backend/MenuGreen.API/Controllers/PtReviewController.cs index 927f16c..2a5ff93 100644 --- a/backend/MenuGreen.API/Controllers/PtReviewController.cs +++ b/backend/MenuGreen.API/Controllers/PtReviewController.cs @@ -11,7 +11,7 @@ namespace MenuGreen.API.Controllers { [ApiController] [Route("api/PtReview")] - [Authorize(Policy = "GymerOnly")] + [Authorize(Policy = "GymFeatures")] public class PtReviewController : ControllerBase { private readonly IPtReviewService _service; diff --git a/backend/MenuGreen.API/Program.cs b/backend/MenuGreen.API/Program.cs index 0f7c279..d5fc956 100644 --- a/backend/MenuGreen.API/Program.cs +++ b/backend/MenuGreen.API/Program.cs @@ -103,9 +103,12 @@ builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); + // Base user APIs only require a valid account token. Product tiers are + // enforced by entitlement policies so stale legacy role claims cannot + // hide an otherwise active subscription. options.AddPolicy( "UserOnly", - policy => policy.RequireRole("Admin", "User", "Free", "Casual", "Gymer", "Office", "Coach") + policy => policy.RequireAuthenticatedUser() ); options.AddPolicy("CoachOnly", policy => policy.RequireRole("Coach", "Admin")); options.AddPolicy( @@ -137,7 +140,7 @@ ) ); options.AddPolicy( - "GymerOnly", + "GymFeatures", policy => policy.Requirements.Add( new MenuGreen.API.Authorization.EntitlementRequirement("gym_features") diff --git a/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AssignRoleRequest.cs b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AssignRoleRequest.cs index b945175..a0f4cf7 100644 --- a/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AssignRoleRequest.cs +++ b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AssignRoleRequest.cs @@ -5,6 +5,7 @@ namespace MenuGreen.BusinessLogicLayer.DTOs.Requests public class AssignRoleRequest { [Required] - public string Role { get; set; } = string.Empty; // e.g., "Admin", "User", "Manager" + [RegularExpression("(?i)^(User|Coach|Admin)$", ErrorMessage = "Role must be User, Coach, or Admin.")] + public string Role { get; set; } = string.Empty; } } diff --git a/backend/MenuGreen.BusinessLogicLayer/Services/NutritionTrackingService.cs b/backend/MenuGreen.BusinessLogicLayer/Services/NutritionTrackingService.cs index 61706cc..8626ac3 100644 --- a/backend/MenuGreen.BusinessLogicLayer/Services/NutritionTrackingService.cs +++ b/backend/MenuGreen.BusinessLogicLayer/Services/NutritionTrackingService.cs @@ -21,6 +21,7 @@ public class NutritionTrackingService : INutritionTrackingService private readonly IPortionConverterService _portionConverterService; private readonly ICacheService _cache; private readonly IPortionNutritionCalculator _portionCalculator; + private readonly IFeatureAccessService _featureAccessService; public NutritionTrackingService( IUnitOfWork unitOfWork, @@ -28,7 +29,8 @@ public NutritionTrackingService( IRecipeService recipeService, IPortionConverterService portionConverterService, ICacheService cache, - IPortionNutritionCalculator portionCalculator) + IPortionNutritionCalculator portionCalculator, + IFeatureAccessService featureAccessService) { _unitOfWork = unitOfWork; _nutritionSnapshotService = nutritionSnapshotService; @@ -36,6 +38,7 @@ public NutritionTrackingService( _portionConverterService = portionConverterService; _cache = cache; _portionCalculator = portionCalculator; + _featureAccessService = featureAccessService; } public async Task CreateMealLogAsync(Guid userId, MealLogUpsertRequest request) @@ -882,15 +885,9 @@ private async Task BuildNutritionTargetContextAsync( BaseFatG = health?.TargetFatG ?? 0 }; - var user = await _unitOfWork.Users.GetByIdAsync(userId); - if (user == null) - { - return context; - } - - var role = (await _unitOfWork.Roles.FindAsync( - item => item.Id == user.RoleId)).FirstOrDefault(); - if (!string.Equals(role?.Name, "Gymer", StringComparison.OrdinalIgnoreCase)) + if (!await _featureAccessService.HasEntitlementAsync( + userId, + FeatureAccessResolver.GymFeatures)) { return context; } diff --git a/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs b/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs index 5e348fa..66b8a33 100644 --- a/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs +++ b/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs @@ -6,12 +6,12 @@ using MenuGreen.BusinessLogicLayer.DTOs.Responses; using MenuGreen.BusinessLogicLayer.Interfaces; using MenuGreen.DataAccessLayer.Interfaces; -using MenuGreen.DataAccessLayer.Entities; namespace MenuGreen.BusinessLogicLayer.Services { public class UserService : IUserService { + private static readonly string[] AssignableRoles = ["User", "Coach", "Admin"]; private readonly IUnitOfWork _unitOfWork; public UserService(IUnitOfWork unitOfWork) @@ -140,21 +140,15 @@ public async Task AssignRoleAsync(Guid userId, string newRole) var user = await _unitOfWork.Users.GetByIdAsync(userId); if (user == null) throw new Exception("Account not found."); - var roles = await _unitOfWork.Roles.FindAsync(r => r.Name.ToLower() == newRole.ToLower()); - var role = roles.FirstOrDefault(); - if (role == null) - { - role = new Role - { - Id = Guid.NewGuid(), - Name = newRole, - Description = $"{newRole} Role", - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - await _unitOfWork.Roles.AddAsync(role); - await _unitOfWork.CompleteAsync(); - } + var canonicalRole = AssignableRoles.FirstOrDefault(role => + string.Equals(role, newRole?.Trim(), StringComparison.OrdinalIgnoreCase)); + if (canonicalRole == null) + throw new ArgumentException("Role must be User, Coach, or Admin.", nameof(newRole)); + + var roles = await _unitOfWork.Roles.FindAsync(r => + r.Name.ToLower() == canonicalRole.ToLower()); + var role = roles.FirstOrDefault() + ?? throw new Exception($"Configured role '{canonicalRole}' was not found."); user.RoleId = role.Id; user.UpdatedAt = DateTime.UtcNow; diff --git a/backend/database/01_roles.sql b/backend/database/01_roles.sql index ab5ebac..6a5bb73 100644 --- a/backend/database/01_roles.sql +++ b/backend/database/01_roles.sql @@ -17,12 +17,9 @@ CREATE TABLE roles ( INSERT INTO roles ("Id", "Name", "Description", "CreatedAt", "UpdatedAt") VALUES -('00000000-0000-0000-0000-000000000001', 'Free', 'Free tier user', now(), now()), +('00000000-0000-0000-0000-000000000001', 'User', 'Standard application user', now(), now()), ('00000000-0000-0000-0000-000000000004', 'Admin', 'System administrator', now(), now()), -('00000000-0000-0000-0000-000000000005', 'Casual', 'Diet undecided or casual user', now(), now()), -('00000000-0000-0000-0000-000000000006', 'Gymer', 'Fitness and gym enthusiast', now(), now()), -('00000000-0000-0000-0000-000000000007', 'Office', 'Office and desk worker', now(), now()), ('00000000-0000-0000-0000-000000000008', 'Coach', 'Personal trainer / Nutrition coach', now(), now()) ON CONFLICT DO NOTHING; -COMMIT; \ No newline at end of file +COMMIT; diff --git a/backend/database/02_users.sql b/backend/database/02_users.sql index 7127686..6e7cc1f 100644 --- a/backend/database/02_users.sql +++ b/backend/database/02_users.sql @@ -27,13 +27,13 @@ VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '00000000-0000-0000-0000-000000000001', 'demo@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('cccccccc-cccc-cccc-cccc-cccccccccccc', '00000000-0000-0000-0000-000000000001', 'free@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('dddddddd-dddd-dddd-dddd-dddddddddddd', '00000000-0000-0000-0000-000000000001', 'free_user@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), -('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '00000000-0000-0000-0000-000000000005', 'casual@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), -('ffffffff-ffff-ffff-ffff-ffffffffffff', '00000000-0000-0000-0000-000000000006', 'gymer@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), +('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '00000000-0000-0000-0000-000000000001', 'casual@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), +('ffffffff-ffff-ffff-ffff-ffffffffffff', '00000000-0000-0000-0000-000000000001', 'gymer@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('885810e8-168f-4608-a72e-e23a20dfd258', '00000000-0000-0000-0000-000000000001', 'nguyễnvănbình@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('48069bd5-f29a-417d-bdeb-c00797968aca', '00000000-0000-0000-0000-000000000001', 'trầnthịhoa@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('9afb13a5-e5a1-4342-9ce1-33bf7cc1de70', '00000000-0000-0000-0000-000000000001', 'phạmminhtuấn@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('081b4669-b97f-4e75-b089-4c8de0151653', '00000000-0000-0000-0000-000000000001', 'lêthịmai@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), -('586209d0-d3c4-43a4-bba7-5d4c73b37bc1', '00000000-0000-0000-0000-000000000007', 'office@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), +('586209d0-d3c4-43a4-bba7-5d4c73b37bc1', '00000000-0000-0000-0000-000000000001', 'office@menugreen.app', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('b022ccde-0aa6-4b11-bd7b-f76aaf2c2b17', '00000000-0000-0000-0000-000000000001', 'vũthuhà@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('453681f7-f489-47ed-842c-bc3ffd220423', '00000000-0000-0000-0000-000000000001', 'phanhuyhoàng@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), ('396f9dff-6c2a-422f-b0cc-8eb451168ed3', '00000000-0000-0000-0000-000000000001', 'đỗmỹlinh@gmail.com', '$2b$12$zcokeEBiEkl4iukyN0j6nev0lINFlC8ol8dje1O/JfY2yOHM0arey', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL), @@ -42,4 +42,4 @@ VALUES ('77777777-7777-7777-7777-777777777777', '00000000-0000-0000-0000-000000000008', 'coach@menugreen.app', '$2a$12$9rfP3ktSdK.lDRXFTuHqCOR4EOG7zTZZLL8aD4R2UcBGwNTdSg1D2', true, true, now() - interval '1 day', now() - interval '30 days', now() - interval '30 days', NULL) ON CONFLICT DO NOTHING; -COMMIT; \ No newline at end of file +COMMIT; diff --git a/backend/database/README.md b/backend/database/README.md index 4fd1e67..53f8bd9 100644 --- a/backend/database/README.md +++ b/backend/database/README.md @@ -8,3 +8,50 @@ When adding a field, update the owning table's `CREATE TABLE` statement and cano The 500-food catalog is embedded directly in `15_ingredients.sql` through `18_recipe_ingredients.sql`. Generated nutrition values and prices are planning estimates and should be reviewed before they are presented as authoritative values. + +## Normalize legacy subscription roles in an existing database + +The canonical account roles are `User`, `Coach`, and `Admin`. Subscription tiers +(`Free`, `Casual`, `Gymer`, and `Office`) belong in `user_subscriptions`, not in +`roles`. Do not rerun `01_roles.sql` against an existing database because it drops +the table with `CASCADE`. Apply the following transaction once instead: + +```sql +BEGIN; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM roles + WHERE "Id" = '00000000-0000-0000-0000-000000000001' + ) THEN + RAISE EXCEPTION 'Canonical User role is missing'; + END IF; +END $$; + +UPDATE users +SET "RoleId" = '00000000-0000-0000-0000-000000000001', + "UpdatedAt" = now() +WHERE "RoleId" IN ( + SELECT "Id" + FROM roles + WHERE lower("Name") IN ('user', 'free', 'casual', 'gymer', 'office') +); + +DELETE FROM roles +WHERE "Id" <> '00000000-0000-0000-0000-000000000001' + AND lower("Name") IN ('user', 'free', 'casual', 'gymer', 'office'); + +UPDATE roles +SET "Name" = 'User', + "Description" = 'Standard application user', + "UpdatedAt" = now() +WHERE "Id" = '00000000-0000-0000-0000-000000000001'; + +COMMIT; +``` + +Existing access tokens may continue to display a legacy role until refreshed, +but `UserOnly` endpoints authorize any valid authenticated account. Subscription +features continue to be enforced by their entitlement policies. diff --git a/docs/02-backend/README_BACKEND_OVERVIEW.md b/docs/02-backend/README_BACKEND_OVERVIEW.md index 460e664..170d77d 100644 --- a/docs/02-backend/README_BACKEND_OVERVIEW.md +++ b/docs/02-backend/README_BACKEND_OVERVIEW.md @@ -131,7 +131,7 @@ Chi tiết từng entity (fields, relationships): xem [`backend_models_documenta - Policies: `AdminOnly`, `UserOnly`, `CoachOnly`. - Controller admin bắt buộc role `Admin` (check qua policy `AdminOnly`). -- API user thông thường bắt buộc `[Authorize]` + policy `UserOnly` (chấp nhận role `User`, `Admin`, `Free`, `Casual`, `Gymer`, `Office`, `Coach`). Role `Pro` đã được gỡ khỏi policy vì không còn bán gói `Pro` riêng. +- API người dùng thông thường bắt buộc `[Authorize]` + policy `UserOnly` (yêu cầu JWT hợp lệ, không phụ thuộc role claim). Các gói `Free`, `Casual`, `Gym`, `Office` được phân quyền bằng subscription entitlement; `CoachOnly` và `AdminOnly` vẫn kiểm tra role riêng. - API coach bắt buộc policy `CoachOnly` (chấp nhận role `Coach`, `Admin`). ### 3.4 Password hashing diff --git a/docs/05-reports/backend_free_feature_access_activity.md b/docs/05-reports/backend_free_feature_access_activity.md index ef0c033..0ada730 100644 --- a/docs/05-reports/backend_free_feature_access_activity.md +++ b/docs/05-reports/backend_free_feature_access_activity.md @@ -22,7 +22,7 @@ Một tài khoản có thể có nhiều subscription đang active. Quyền hi - `subscription_plans.FeatureGroup` đã chứa các giá trị như `office` và `gym`. - `GET /api/UserSubscription/me/active` đã trả về toàn bộ subscription còn hiệu lực. - `EntitlementHandler` đã nhận biết `gym_features`, `coach_access` và `office_features`. -- Policy `GymerOnly` đã dùng entitlement. +- Policy `GymFeatures` đã dùng entitlement. - Policy `OfficeOnly` hiện dựa vào role `Office` hoặc `Admin`, chưa thống nhất với cách kiểm tra subscription của Gym. - `UserDashboardService` mặc định tên gói là `Free` khi người dùng không có subscription. @@ -75,10 +75,10 @@ Một tài khoản có thể có nhiều subscription đang active. Quyền hi ### Backend API -- Bổ sung policy `OfficeOnly` sử dụng `EntitlementRequirement("office_features")`, tương tự `GymerOnly`. +- Bổ sung policy `OfficeOnly` sử dụng `EntitlementRequirement("office_features")`, tương tự `GymFeatures`. - Chuẩn hóa `FeatureGroup` về các giá trị máy đọc được: `free`, `office`, `gym`, `coach`, `premium`. - Gắn `[Authorize(Policy = "OfficeOnly")]` vào toàn bộ controller/action Office. -- Gắn `[Authorize(Policy = "GymerOnly")]` vào toàn bộ controller/action Gym/PT. +- Gắn `[Authorize(Policy = "GymFeatures")]` vào toàn bộ controller/action Gym/PT. - Giữ API Free với `[Authorize(Policy = "UserOnly")]`. - Không dùng role giao diện hoặc `aiProfile.mode` thay cho entitlement trả phí. @@ -146,7 +146,7 @@ Người dùng có cả Office và Gym/PT: ## 9. Tiêu chí hoàn thành - Không có API Office/Gym nào chỉ dựa vào việc frontend ẩn nút. -- `OfficeOnly` và `GymerOnly` đều dựa trên entitlement còn hiệu lực. +- `OfficeOnly` và `GymFeatures` đều dựa trên entitlement còn hiệu lực. - Người dùng Free vẫn truy cập đầy đủ API cơ bản. - Kết quả phân quyền nhất quán giữa API entitlement, policy và dữ liệu subscription. - Có unit test và integration test cho ma trận Free/Office/Gym/PT. diff --git a/docs/07-issues/issues.md b/docs/07-issues/issues.md index 83f58c0..6c69c3f 100644 --- a/docs/07-issues/issues.md +++ b/docs/07-issues/issues.md @@ -2227,7 +2227,7 @@ CS86xx cũ trong `MealPlanService.cs`, `AiAssistantService.cs`, ### Lessons Learned -- Phân biệt rõ policy **role-based** (`UserOnly`, `GymerOnly`) và policy +- Phân biệt rõ policy **role-based** (`UserOnly`) và policy **entitlement-based** (`OfficeFeatures`, `GymFeatures`). Method-level attribute cộng dồn với controller-level attribute → phải đọc cả hai. - Khi thấy 403 ở một endpoint nhưng 200 ở endpoint khác cùng controller, tra diff --git a/docs/workflow/user_api_contract_user.md b/docs/workflow/user_api_contract_user.md index 2ac54b9..bc44e7c 100644 --- a/docs/workflow/user_api_contract_user.md +++ b/docs/workflow/user_api_contract_user.md @@ -42,7 +42,7 @@ Nếu bất kỳ phần nào không hợp lệ, transaction rollback: không lư | PT gửi nhận xét bằng token | `POST /PtReview/shared-reports/{token}/submit` (không cần đăng nhập) | | Người dùng xem/áp dụng/từ chối | `GET /PtReview/requests/{id}/result`, `POST /PtReview/requests/{id}/apply`, `POST /PtReview/requests/{id}/reject` | -`PtReview` hiện dùng policy role `GymerOnly`. Việc phân quyền theo subscription/entitlement sẽ chỉ được thực hiện sau khi các luồng nhóm người dùng hoàn chỉnh. +`PtReview` dùng policy entitlement `GymFeatures`; người dùng cần subscription Gym đang hoạt động. ## Thanh toán subscription qua SePay diff --git a/frontend-web/components/ui/badge.tsx b/frontend-web/components/ui/badge.tsx index 23460e0..3ff2d51 100644 --- a/frontend-web/components/ui/badge.tsx +++ b/frontend-web/components/ui/badge.tsx @@ -40,11 +40,9 @@ export function roleBadgeVariant(role: string): BadgeVariant { switch (role) { case "Admin": return "danger"; - case "Pro": - return "info"; case "Coach": return "warning"; - case "Free": + case "User": return "success"; default: return "neutral"; diff --git a/frontend-web/features/users/components/assign-role-dialog.tsx b/frontend-web/features/users/components/assign-role-dialog.tsx index 27a53e3..1eb8290 100644 --- a/frontend-web/features/users/components/assign-role-dialog.tsx +++ b/frontend-web/features/users/components/assign-role-dialog.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Select } from "@/components/ui/select"; import type { UserAdmin } from "@/features/users/types"; -const ROLE_OPTIONS = ["Free", "Pro", "User", "Coach", "Admin"]; +const ROLE_OPTIONS = ["User", "Coach", "Admin"]; interface AssignRoleDialogProps { user: UserAdmin | null; @@ -22,10 +22,6 @@ export function AssignRoleDialog({ }: AssignRoleDialogProps) { const [role, setRole] = useState(user?.role ?? "User"); - useEffect(() => { - if (user) setRole(user.role); - }, [user]); - if (!user) return null; return ( diff --git a/frontend-web/features/users/components/user-management.tsx b/frontend-web/features/users/components/user-management.tsx index d085360..8461f31 100644 --- a/frontend-web/features/users/components/user-management.tsx +++ b/frontend-web/features/users/components/user-management.tsx @@ -167,6 +167,7 @@ export function UserManagement() { setSelectedUser(null)} diff --git a/frontend/lib/core/i18n/api_message_translator_fixed.dart b/frontend/lib/core/i18n/api_message_translator_fixed.dart new file mode 100644 index 0000000..6439d76 --- /dev/null +++ b/frontend/lib/core/i18n/api_message_translator_fixed.dart @@ -0,0 +1,994 @@ +import 'dart:convert'; + +/// Dịch message tiếng Anh từ API sang tiếng Việt trước khi hiển thị cho user. +class ApiMessageTranslator { + ApiMessageTranslator._(); + + static const _dataDictionary = { + // Meal Types + 'breakfast': 'Bữa sáng', + 'lunch': 'Bữa trưa', + 'dinner': 'Bữa tối', + 'snack': 'Ăn vặt', + + // Statuses + 'pending': 'Chờ duyệt', + 'accepted': 'Đã duyệt', + 'approved': 'Đã duyệt', + 'rejected': 'Từ chối', + 'completed': 'Hoàn thành', + 'active': 'Đang hoạt động', + 'inactive': 'Ngừng hoạt động', + + // Categories & Common Terms + 'meat': 'Thịt', + 'vegetable': 'Rau củ', + 'vegetables': 'Rau củ', + 'fruit': 'Trái cây', + 'fruits': 'Trái cây', + 'dairy': 'Sữa', + 'seafood': 'Hải sản', + 'carb': 'Tinh bột', + 'carbs': 'Tinh bột', + 'protein': 'Đạm', + 'fat': 'Chất béo', + 'weight loss': 'Giảm cân', + 'muscle gain': 'Tăng cơ', + 'maintenance': 'Giữ dáng', + 'sedentary': 'Ít vận động', + + // Others + 'casual': 'Thông thường', + 'office': 'Văn phòng', + 'coach': 'Huấn luyện viên', + 'gymer': 'Người tập gym', + 'trainer': 'Huấn luyện viên', + }; + + /// Dịch dữ liệu động (Data payload) từ backend như mealType, status, category... + static String translateData(String? data) { + if (data == null || data.trim().isEmpty) return ''; + final key = data.trim().toLowerCase(); + return _dataDictionary[key] ?? data; + } + + static const _exact = { + // Auth + 'OTP verified successfully.': 'Xác thực OTP thành công.', + 'Invalid or expired OTP.': 'OTP không hợp lệ hoặc đã hết hạn.', + 'Logged out successfully.': 'Đăng xuất thành công.', + 'Email is required.': 'Vui lòng nhập email.', + 'Invalid email format.': 'Email không đúng định dạng.', + 'Password is required.': 'Vui lòng nhập mật khẩu.', + 'Invalid username or password.': + 'Tên đăng nhập hoặc mật khẩu không chính xác.', + 'Email already exists.': 'Email này đã được đăng ký tài khoản.', + 'User not found.': 'Không tìm thấy thông tin tài khoản.', + 'Unauthorized': 'Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.', + 'Unauthorized.': 'Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.', + 'Bad Request': 'Yêu cầu không hợp lệ. Vui lòng kiểm tra lại.', + 'Internal Server Error': 'Máy chủ gặp sự cố. Vui lòng thử lại sau.', + 'An error occurred while processing your request.': + 'Đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại sau.', + + // Not found / forbidden + 'Recipe not found.': 'Không tìm thấy công thức.', + 'Food not found.': 'Không tìm thấy món ăn.', + 'Ingredient not found.': 'Không tìm thấy nguyên liệu.', + 'Notification not found.': 'Không tìm thấy thông báo.', + 'Forbidden.': 'Bạn không có quyền thực hiện thao tác này.', + 'User subscription not found.': 'Không tìm thấy gói đăng ký.', + 'Subscription plan not found or inactive.': + 'Gói dịch vụ không tồn tại hoặc đã ngừng.', + 'Current password is incorrect.': 'Mật khẩu hiện tại không đúng.', + 'Confirm password does not match.': 'Mật khẩu xác nhận không khớp.', + 'Meal log not found.': 'Không tìm thấy nhật ký bữa ăn.', + 'Meal plan not found.': 'Không tìm thấy kế hoạch bữa ăn.', + 'Meal plan item not found.': 'Không tìm thấy mục trong kế hoạch.', + 'Meal plan must contain at least one item.': + 'Kế hoạch cần ít nhất một bữa.', + 'Each meal plan item must have either FoodId or RecipeId.': + 'Mỗi bữa trong kế hoạch cần chọn món hoặc công thức.', + 'Meal plan item must have FoodId or RecipeId.': + 'Mục kế hoạch cần món ăn hoặc công thức.', + 'FoodId, RecipeId, or CustomName is required.': + 'Vui lòng chọn món ăn, công thức hoặc nhập tên món.', + 'PlannedDate is required.': 'Vui lòng chọn ngày cho kế hoạch.', + 'QuantityG must be greater than 0.': 'Khẩu phần ăn phải lớn hơn 0g.', + 'Meal reminder is disabled.': 'Đã tắt nhắc giờ ăn.', + 'Prep reminder is disabled.': 'Đã tắt nhắc chuẩn bị nấu.', + 'Weight log not found.': 'Không tìm thấy nhật ký cân nặng.', + 'FoodId or RecipeId is required.': 'Cần chọn món ăn hoặc công thức.', + 'Please complete health profile before finishing onboarding.': + 'Vui lòng nhập thông số sức khỏe trước khi hoàn tất thiết lập.', + 'Height and weight are required.': 'Chiều cao và cân nặng là bắt buộc.', + 'Account not found. Please sign out and sign in again.': + 'Tài khoản không tồn tại. Vui lòng đăng xuất và đăng nhập lại.', + 'Invalid preference data. Please try again.': + 'Dữ liệu sở thích không hợp lệ. Vui lòng thử lại.', + 'Cannot save AI profile. Please try again later.': + 'Không thể lưu hồ sơ AI. Vui lòng thử lại sau.', + 'Database schema is outdated. Please run migrations.': + 'Cơ sở dữ liệu chưa cập nhật. Vui lòng thử lại sau.', + 'Payment failed or cancelled.': + 'Thanh toán không thành công hoặc đã bị hủy.', + 'Cannot update a completed meal plan.': + 'Không thể chỉnh sửa kế hoạch đã hoàn thành.', + + // Rate Limiting (429 Too Many Requests) + 'Too Many Requests': 'Quá nhiều yêu cầu. Vui lòng thử lại sau.', + 'Rate limit exceeded': + 'Bạn đã gửi quá nhiều yêu cầu. Vui lòng thử lại sau.', + 'Rate limit exceeded. Please try again later.': + 'Bạn đã gửi quá nhiều yêu cầu. Vui lòng thử lại sau.', + 'AI service is rate-limited. Please try again in a few minutes.': + 'Dịch vụ AI đang bận. Vui lòng thử lại sau vài phút.', + + // Nutrition warnings (English from API) + 'Calorie intake deviates more than 10% from daily target.': + 'Calo lệch hơn 10% so với mục tiêu ngày.', + 'Current weight is confirmed, but there are not enough consistent weight logs to adjust calories safely. Keeping the current target.': + 'Đã xác nhận cân nặng hiện tại nhưng chưa đủ dữ liệu nhất quán để điều chỉnh calo an toàn. Hệ thống giữ nguyên mục tiêu hiện tại.', + + // DailyStarter / VietnamLocal + 'Menu template applied to today\'s plan successfully.': + 'Đã áp dụng mẫu thực đơn cho kế hoạch hôm nay.', + 'Meal added to today\'s plan successfully.': + 'Đã thêm món vào kế hoạch hôm nay.', + 'Feedback saved successfully.': 'Đã lưu phản hồi của bạn.', + 'Saved as quick add successfully.': 'Đã lưu làm món ăn nhanh.', + 'Configuration deleted successfully.': 'Đã xóa cấu hình.', + 'Ingredient substitution in plan applied successfully.': + 'Đã thay thế nguyên liệu trong kế hoạch.', + 'Actual ingredient substitution recorded successfully.': + 'Đã ghi nhận thay thế nguyên liệu thực tế.', + 'FoodId or MealLogId is required.': 'Cần chọn món ăn hoặc nhật ký bữa ăn.', + 'Consent updated and saved to AI Profile preferences.': + 'Đã cập nhật đồng ý và lưu vào hồ sơ AI.', + 'Recalibration data collected and target calories updated successfully.': + 'Đã thu thập dữ liệu và cập nhật calo mục tiêu.', + 'A future meal cannot be marked as eaten.': + 'Bữa ăn trong tương lai không thể đánh dấu là đã ăn.', + 'A future meal item cannot be marked as eaten.': + 'Không thể đánh dấu đã ăn cho bữa ăn trong tương lai.', + 'Cannot mark a future meal as eaten.': + 'Không thể đánh dấu đã ăn cho bữa ăn trong tương lai.', + + // PT Review / Coach weekly report + 'Review request does not exist.': 'Không tìm thấy yêu cầu đánh giá.', + 'Review request does not exist or token is invalid.': + 'Không tìm thấy yêu cầu đánh giá hoặc mã token không hợp lệ.', + 'Link has expired.': 'Liên kết đã hết hạn.', + 'This review request has already been responded to or applied.': + 'Yêu cầu đánh giá đã được phản hồi hoặc đã áp dụng.', + 'Review request has not been responded to by PT or has already been processed.': + 'Yêu cầu chưa được PT phản hồi hoặc đã được xử lý.', + 'Access denied.': 'Bạn không có quyền truy cập.', + + // Coach Meal Plan + 'Meal plan items cannot be null.': + 'Danh sách món trong lộ trình không được để trống.', + 'Please set up a budget (Budget Request) before automatically generating a plan.': + 'Vui lòng thiết lập ngân sách trước khi tự động tạo lộ trình.', + 'Meal plan not found or access denied.': + 'Không tìm thấy lộ trình hoặc bạn không có quyền truy cập.', + + // Phase 8: PersonalProgram (Coach -> Gymer direction) + 'Personal program does not exist.': 'Không tìm thấy lộ trình cá nhân.', + 'This request was not sent by your coach.': + 'Yêu cầu này không được gửi từ PT của bạn.', + 'Personal program has already been processed.': + 'Lộ trình cá nhân đã được xử lý.', + 'You are not connected with this client.': + 'Bạn chưa kết nối với khách hàng này.', + 'Client already has a pending personal program. Please wait for them to respond before sending a new one.': + 'Khách hàng đang có lộ trình cá nhân chờ phản hồi. Vui lòng chờ họ xử lý trước khi gửi lộ trình mới.', + + // Coach connection request notifications (English from CoachService.cs) + 'New student connection request': 'Yêu cầu kết nối từ học viên mới', + 'Connection request accepted': 'Yêu cầu kết nối đã được chấp nhận', + 'Connection request rejected': 'Yêu cầu kết nối đã bị từ chối', + 'New advice from Coach': 'Lời khuyên mới từ PT / Coach', + 'Meal plan has been adjusted': 'Thực đơn đã được điều chỉnh', + 'Nutrition targets have been updated': 'Chỉ số dinh dưỡng đã được cập nhật', + + // Recalibrate gym goals — needs recent weight data + 'No weight data in the last 7 days. Please log your weight before recalibrating.': + 'Vui lòng cập nhật cân nặng ít nhất 1 lần trong 7 ngày qua trước khi hiệu chỉnh mục tiêu.', + + // --- Backend API Auto-Translations --- + 'If the email exists, an OTP has been sent.': + 'Nếu email tồn tại, mã OTP đã được gửi đến bạn.', + 'You do not have permission to edit this custom unit.': + 'Bạn không có quyền chỉnh sửa đơn vị tùy chỉnh này.', + 'Coach application not found.': + 'Không tìm thấy đơn đăng ký Huấn luyện viên.', + 'Client does not exist.': 'Khách hàng không tồn tại.', + 'Default serving weight must be greater than 0 grams.': + 'Khối lượng phục vụ mặc định phải lớn hơn 0 gam.', + 'Feedback updated successfully.': 'Cập nhật phản hồi thành công.', + 'The Basic plan is always available and does not require renewal.': + 'Gói Basic luôn có sẵn và không yêu cầu gia hạn.', + 'Conversation not found.': 'Không tìm thấy cuộc trò chuyện.', + 'Budget configuration deleted successfully.': + 'Đã xóa cấu hình ngân sách thành công.', + 'Meal template must contain at least one item.': + 'Mẫu bữa ăn phải chứa ít nhất một mục.', + 'Original ingredient quantity must be greater than 0.': + 'Số lượng nguyên liệu gốc phải lớn hơn 0.', + 'Student has not set up their health profile.': + 'Học viên chưa thiết lập hồ sơ sức khỏe.', + 'This meal plan was sent to another coach.': + 'Kế hoạch bữa ăn này đã được gửi cho một huấn luyện viên khác.', + 'Food log not found.': 'Không tìm thấy nhật ký món ăn.', + 'Each meal plan item must have either FoodId, RecipeId, or CustomName.': + 'Mỗi mục kế hoạch bữa ăn phải có FoodId, RecipeId hoặc Tên tùy chỉnh.', + 'Account type must be User or PT.': + 'Loại tài khoản phải là Người dùng hoặc PT.', + 'Internal server error.': 'Lỗi máy chủ nội bộ.', + 'Payment order cancelled successfully.': + 'Đã hủy đơn hàng thanh toán thành công.', + 'You already have a pending SePay payment. Complete or wait for it to expire before creating a new order.': + 'Bạn đang có một khoản thanh toán SePay chờ xử lý. Hoàn thành hoặc đợi hết hạn trước khi tạo đơn hàng mới.', + 'Conversation deleted successfully.': 'Đã xóa cuộc trò chuyện thành công.', + 'User premium program registration not found.': + 'Không tìm thấy đăng ký chương trình Premium của người dùng.', + 'Account not found.': 'Không tìm thấy tài khoản.', + 'Unsupported review decision.': 'Quyết định đánh giá không được hỗ trợ.', + 'JwtSettings:SecretKey is not configured.': + 'JwtSettings:SecretKey chưa được cấu hình.', + 'PriceVnd must be greater than or equal to 0.': + 'Giá VNĐ phải lớn hơn hoặc bằng 0.', + 'The start date must be before or equal to the end date.': + 'Ngày bắt đầu phải trước hoặc bằng ngày kết thúc.', + 'Meal template deleted successfully.': 'Đã xóa mẫu bữa ăn thành công.', + 'Quantity is required.': 'Số lượng là bắt buộc.', + 'At least one professional certificate is required.': + 'Cần ít nhất một chứng chỉ chuyên môn.', + 'Meal plan deleted successfully.': 'Đã xóa kế hoạch bữa ăn thành công.', + 'Deleted successfully.': 'Đã xóa thành công.', + 'Snooze duration must be between 1 and 1440 minutes.': + 'Thời gian báo lại phải từ 1 đến 1440 phút.', + 'Empty webhook body.': 'Nội dung webhook trống.', + 'Your nutrition data is stable, no goal drift detected.': + 'Dữ liệu dinh dưỡng của bạn ổn định, không phát hiện sai lệch mục tiêu.', + 'Subscription plan not found.': 'Không tìm thấy gói đăng ký.', + 'Full name must not exceed 255 characters.': + 'Họ và tên không được vượt quá 255 ký tự.', + 'This plan is free and cannot be renewed via SePay.': + 'Gói này miễn phí và không thể gia hạn qua SePay.', + 'Name is required.': 'Tên là bắt buộc.', + 'Coach profile not found.': 'Không tìm thấy hồ sơ huấn luyện viên.', + 'Biography must contain at least 80 characters.': + 'Tiểu sử phải chứa ít nhất 80 ký tự.', + 'FoodId or RecipeId or custom nutritional values are required.': + 'Yêu cầu có FoodId, RecipeId hoặc giá trị dinh dưỡng tùy chỉnh.', + 'Food is inactive and cannot be added to favorites.': + 'Món ăn không hoạt động và không thể thêm vào mục yêu thích.', + 'Report not found.': 'Không tìm thấy báo cáo.', + 'No current goal drift alert.': + 'Không có cảnh báo sai lệch mục tiêu hiện tại.', + 'Password changed successfully.': 'Đổi mật khẩu thành công.', + 'SePay webhook request expired.': 'Yêu cầu webhook SePay đã hết hạn.', + 'Hip measurement must be between 30cm and 250cm.': + 'Số đo vòng hông phải từ 30cm đến 250cm.', + 'ConnectionStrings__DefaultConnection environment variable is not configured.': + 'Biến môi trường ConnectionStrings__DefaultConnection chưa được cấu hình.', + 'NotificationIds list cannot be empty': + 'Danh sách NotificationIds không được để trống.', + 'Please enter your date of birth in your profile first to calculate targets.': + 'Vui lòng nhập ngày sinh trong hồ sơ của bạn trước để tính toán mục tiêu.', + 'You do not have a valid coaching connection with this student.': + 'Bạn không có kết nối huấn luyện hợp lệ với học viên này.', + "You do not have access to this student's feedback.": + 'Bạn không có quyền truy cập vào phản hồi của học viên này.', + 'Program enrollment not found.': 'Không tìm thấy đăng ký chương trình.', + 'This meal plan does not belong to the specified student.': + 'Kế hoạch bữa ăn này không thuộc về học viên được chỉ định.', + 'You do not have any active Premium programs.': + 'Bạn không có chương trình Premium nào đang hoạt động.', + 'Requested food/dish not found.': + 'Không tìm thấy món ăn/thực phẩm yêu cầu.', + 'AI service did not return a job ID.': + 'Dịch vụ AI không trả về ID công việc.', + 'This program has not been paid for or is already activated.': + 'Chương trình này chưa được thanh toán hoặc đã được kích hoạt.', + 'Recommendation not found.': 'Không tìm thấy gợi ý.', + 'Invalid or expired Google sign-in token.': + 'Mã đăng nhập Google không hợp lệ hoặc đã hết hạn.', + 'Equivalent weight is required.': 'Trọng lượng tương đương là bắt buộc.', + 'Equivalent weight must be between 0.1g and 10000g.': + 'Trọng lượng tương đương phải từ 0.1g đến 10000g.', + 'Target calories is required.': 'Lượng calo mục tiêu là bắt buộc.', + 'Years of experience is required.': 'Số năm kinh nghiệm là bắt buộc.', + 'Start date is required.': 'Ngày bắt đầu là bắt buộc.', + 'A reason is required for this decision.': + 'Cần có lý do cho quyết định này.', + 'Outgoing transfer ignored.': 'Giao dịch chuyển đi bị bỏ qua.', + 'Added to favorites successfully.': 'Đã thêm vào mục yêu thích thành công.', + 'Bio is required.': 'Tiểu sử là bắt buộc.', + 'Data access granted to Coach.': + 'Đã cấp quyền truy cập dữ liệu cho Huấn luyện viên.', + 'Payment already marked as paid.': 'Thanh toán đã được đánh dấu là đã trả.', + 'Goal drift alert not found.': 'Không tìm thấy cảnh báo sai lệch mục tiêu.', + 'Password must be at least 6 characters long.': + 'Mật khẩu phải dài ít nhất 6 ký tự.', + 'Conversion unit is required.': 'Đơn vị chuyển đổi là bắt buộc.', + 'Invalid email or password.': 'Email hoặc mật khẩu không hợp lệ.', + 'Reminder time must be in the future.': + 'Thời gian nhắc nhở phải ở trong tương lai.', + 'Your account has been locked or does not exist.': + 'Tài khoản của bạn đã bị khóa hoặc không tồn tại.', + 'Image stream is missing or empty.': 'Luồng hình ảnh bị thiếu hoặc trống.', + 'DurationDays must be greater than or equal to 0.': + 'DurationDays phải lớn hơn hoặc bằng 0.', + 'Google sign-in is not configured on the server.': + 'Đăng nhập Google chưa được cấu hình trên máy chủ.', + 'FoodId is required when converting a local portion unit.': + 'FoodId là bắt buộc khi chuyển đổi đơn vị khẩu phần cục bộ.', + 'Avatar URL must be a valid URL.': + 'URL hình đại diện phải là một URL hợp lệ.', + 'Notification deleted successfully.': 'Đã xóa thông báo thành công.', + 'Only active subscriptions can be renewed via SePay.': + 'Chỉ các gói đăng ký đang hoạt động mới có thể được gia hạn qua SePay.', + 'Weight must be between 30kg and 300kg.': + 'Cân nặng phải từ 30kg đến 300kg.', + 'The Basic plan is enabled by default and does not require a subscription.': + 'Gói Basic được kích hoạt mặc định và không yêu cầu đăng ký.', + 'Substitute ingredient not found.': 'Không tìm thấy nguyên liệu thay thế.', + 'Cooking time limit must be between 5 minutes and 1,440 minutes.': + 'Giới hạn thời gian nấu phải từ 5 phút đến 1.440 phút.', + 'City is required.': 'Thành phố là bắt buộc.', + 'The application is already being reviewed.': + 'Đơn đăng ký đang được xem xét.', + 'The image analysis task timed out on the AI service.': + 'Nhiệm vụ phân tích hình ảnh đã quá thời gian trên dịch vụ AI.', + 'At least one language is required.': 'Cần ít nhất một ngôn ngữ.', + 'SePay payment not found.': 'Không tìm thấy khoản thanh toán SePay.', + 'CV service is not properly configured.': + 'Dịch vụ CV chưa được cấu hình đúng.', + 'You do not have permission to delete this custom unit.': + 'Bạn không có quyền xóa đơn vị tùy chỉnh này.', + 'Registration successful. Please check your email for the verification OTP.': + 'Đăng ký thành công. Vui lòng kiểm tra email của bạn để lấy mã xác minh OTP.', + 'Image file is required.': 'Tệp hình ảnh là bắt buộc.', + 'Default serving weight is required.': + 'Khối lượng phục vụ mặc định là bắt buộc.', + 'Substitute ingredient quantity must be greater than 0.': + 'Số lượng nguyên liệu thay thế phải lớn hơn 0.', + 'Connection with this coach not found.': + 'Không tìm thấy kết nối với huấn luyện viên này.', + 'This report has already been reviewed or closed.': + 'Báo cáo này đã được đánh giá hoặc đóng lại.', + 'Missing X-SePay-Signature header.': 'Thiếu tiêu đề X-SePay-Signature.', + 'OTP code must be at least 6 characters long.': + 'Mã OTP phải dài ít nhất 6 ký tự.', + 'At least one specialty is required.': 'Cần ít nhất một chuyên môn.', + 'Invalid payment status. Please refresh and try again.': + 'Trạng thái thanh toán không hợp lệ. Vui lòng làm mới và thử lại.', + 'SePay bank account is not configured. Set SePay:BankAccount:AccountNumber and SePay:BankAccount:BankName in appsettings (values from your SePay dashboard).': + 'Tài khoản ngân hàng SePay chưa được cấu hình.', + 'Phone number is required.': 'Số điện thoại là bắt buộc.', + 'Substitution configuration not found.': + 'Không tìm thấy cấu hình thay thế.', + 'Token removed successfully.': 'Đã xóa token thành công.', + 'Active connection with this coach not found.': + 'Không tìm thấy kết nối hoạt động với huấn luyện viên này.', + 'Feedback content is required.': 'Nội dung phản hồi là bắt buộc.', + 'Calories must be positive.': 'Calo phải là số dương.', + 'Reminder not found.': 'Không tìm thấy lời nhắc.', + 'Reminder notification sent successfully.': + 'Đã gửi thông báo nhắc nhở thành công.', + 'You do not have an active Premium program to get report from.': + 'Bạn không có chương trình Premium nào đang hoạt động để lấy báo cáo.', + 'This micro-learning card does not have an attached quiz question.': + 'Thẻ học tập vi mô này không có câu hỏi trắc nghiệm đính kèm.', + 'Invalid SePay webhook API key.': 'Khóa API webhook SePay không hợp lệ.', + 'AI service rejected the API key. Please contact support.': + 'Dịch vụ AI đã từ chối khóa API. Vui lòng liên hệ hỗ trợ.', + 'The application is being reviewed and cannot be edited.': + 'Đơn đăng ký đang được xem xét và không thể chỉnh sửa.', + 'This token is already registered by another user.': + 'Token này đã được đăng ký bởi một người dùng khác.', + 'Campaign not found.': 'Không tìm thấy chiến dịch.', + 'Unit name is required.': 'Tên đơn vị là bắt buộc.', + 'Your issue report has been successfully recorded.': + 'Báo cáo sự cố của bạn đã được ghi nhận thành công.', + 'Micro-learning card not found.': 'Không tìm thấy thẻ học tập vi mô.', + 'Receiver bank account in webhook does not match configured SePay bank account.': + 'Tài khoản ngân hàng người nhận trong webhook không khớp với tài khoản SePay đã cấu hình.', + 'Meal in meal plan not found.': 'Không tìm thấy bữa ăn trong kế hoạch.', + 'SePay:WebhookSecret is not configured.': + 'SePay:WebhookSecret chưa được cấu hình.', + 'The original program has been deleted.': 'Chương trình gốc đã bị xóa.', + 'Duplicate transaction ignored.': 'Giao dịch trùng lặp bị bỏ qua.', + 'Subscription program registration details not found.': + 'Không tìm thấy chi tiết đăng ký chương trình.', + "Invalid action. Only 'read', 'save', 'unsave', 'dismiss' are accepted.": + "Hành động không hợp lệ. Chỉ chấp nhận 'read', 'save', 'unsave', 'dismiss'.", + 'Weight must be between 20kg and 500kg.': + 'Cân nặng phải từ 20kg đến 500kg.', + 'Unsupported AI action type.': 'Loại hành động AI không được hỗ trợ.', + 'New password is required.': 'Mật khẩu mới là bắt buộc.', + 'SePay:WebhookApiKey is not configured.': + 'SePay:WebhookApiKey chưa được cấu hình.', + 'You need to graduate before exporting the certificate.': + 'Bạn cần hoàn thành chương trình trước khi xuất chứng chỉ.', + 'Reminder deleted successfully.': 'Đã xóa lời nhắc thành công.', + 'Title is required.': 'Tiêu đề là bắt buộc.', + 'Feedback recorded successfully.': 'Phản hồi đã được ghi nhận thành công.', + 'Conversion quantity must be between 0.01 and 1000.': + 'Số lượng chuyển đổi phải từ 0.01 đến 1000.', + 'Budget request not found.': 'Không tìm thấy yêu cầu ngân sách.', + 'Food ID is required.': 'Food ID là bắt buộc.', + 'budget_vnd is required for budget optimization.': + 'budget_vnd là bắt buộc để tối ưu hóa ngân sách.', + 'weekStart must be yyyy-MM-dd.': 'weekStart phải có định dạng yyyy-MM-dd.', + 'Duplicate transaction ignored (database constraint).': + 'Giao dịch trùng lặp bị bỏ qua (ràng buộc cơ sở dữ liệu).', + 'Unit name cannot exceed 150 characters.': + 'Tên đơn vị không được vượt quá 150 ký tự.', + 'New password must be at least 6 characters long.': + 'Mật khẩu mới phải dài ít nhất 6 ký tự.', + 'Carbs must be positive.': 'Carbs phải là số dương.', + 'Target body fat percentage must be between 1% and 80%.': + 'Tỷ lệ mỡ cơ thể mục tiêu phải từ 1% đến 80%.', + 'Student or Coach does not exist.': + 'Học viên hoặc Huấn luyện viên không tồn tại.', + 'Budget must be between 1,000 VND and 100,000,000 VND.': + 'Ngân sách phải từ 1.000 VNĐ đến 100.000.000 VNĐ.', + 'Transfer content does not contain a valid payment code.': + 'Nội dung chuyển khoản không chứa mã thanh toán hợp lệ.', + 'Meal template not found.': 'Không tìm thấy mẫu bữa ăn.', + 'Missing or invalid Authorization Apikey header.': + 'Thiếu tiêu đề Authorization Apikey hoặc không hợp lệ.', + 'Invalid year.': 'Năm không hợp lệ.', + 'You need to check in for all weeks to graduate from the program.': + 'Bạn cần check-in cho tất cả các tuần để hoàn thành chương trình.', + 'DefaultConnection is not configured.': + 'DefaultConnection chưa được cấu hình.', + 'Cannot cancel a payment that has already been paid.': + 'Không thể hủy khoản thanh toán đã được trả.', + 'Token is required.': 'Token là bắt buộc.', + 'Token not found.': 'Không tìm thấy token.', + 'Program does not exist.': 'Chương trình không tồn tại.', + 'Password reset successful.': 'Đặt lại mật khẩu thành công.', + 'OTP code is required.': 'Mã OTP là bắt buộc.', + 'Dinner time must be in HH:mm format.': + 'Thời gian bữa tối phải ở định dạng HH:mm.', + 'Avatar URL is required.': 'URL hình đại diện là bắt buộc.', + 'Protein must be positive.': 'Protein phải là số dương.', + 'Payment order has expired.': 'Đơn hàng thanh toán đã hết hạn.', + 'food_id or recipe_id is required to schedule a meal.': + 'Cần có food_id hoặc recipe_id để lên lịch bữa ăn.', + 'Missing or invalid X-SePay-Timestamp header.': + 'Thiếu tiêu đề X-SePay-Timestamp hoặc không hợp lệ.', + 'Alerts are derived from meal plan compare and tracking data.': + 'Cảnh báo được tạo từ so sánh kế hoạch bữa ăn và dữ liệu theo dõi.', + 'quantity_g or quantity is required to log a meal.': + 'Bắt buộc có quantity_g hoặc quantity để ghi lại bữa ăn.', + 'Recommendation history deleted successfully.': + 'Đã xóa lịch sử gợi ý thành công.', + 'Unsupported AI recommendation mode.': 'Chế độ gợi ý AI không được hỗ trợ.', + 'OTP code must be 6 characters long.': 'Mã OTP phải dài đúng 6 ký tự.', + 'Invalid SePay webhook signature.': 'Chữ ký webhook SePay không hợp lệ.', + 'Assistant message not found.': 'Không tìm thấy tin nhắn của trợ lý.', + 'The coach account is suspended.': + 'Tài khoản huấn luyện viên đã bị đình chỉ.', + 'Only SePay payments can be cancelled via this endpoint.': + 'Chỉ các khoản thanh toán SePay mới có thể bị hủy qua endpoint này.', + 'Your account has been locked.': 'Tài khoản của bạn đã bị khóa.', + 'Webhook processed successfully.': 'Đã xử lý webhook thành công.', + 'Date of birth is required to calculate your profile and target metrics.': + 'Cần ngày sinh để tính toán hồ sơ và số đo mục tiêu của bạn.', + 'This plan is free. Use POST /api/UserSubscription/subscribe instead.': + 'Gói này miễn phí. Vui lòng sử dụng đăng ký gói miễn phí.', + 'Waist measurement must be between 30cm and 250cm.': + 'Số đo vòng eo phải từ 30cm đến 250cm.', + 'Could not generate a unique SePay payment code. Please try again.': + 'Không thể tạo mã thanh toán SePay duy nhất. Vui lòng thử lại.', + 'No active device tokens found.': + 'Không tìm thấy token thiết bị nào đang hoạt động.', + 'Specialty field is required.': 'Trường chuyên môn là bắt buộc.', + 'No active device tokens found for user.': + 'Không tìm thấy token thiết bị hoạt động cho người dùng.', + 'Password recovery OTP has been sent to your email.': + 'Mã OTP khôi phục mật khẩu đã được gửi đến email của bạn.', + 'Chest measurement must be between 30cm and 250cm.': + 'Số đo vòng ngực phải từ 30cm đến 250cm.', + 'Full name is required.': 'Họ và tên là bắt buộc.', + 'Premium program not found.': 'Không tìm thấy chương trình Premium.', + 'A profile photo is required.': 'Cần có ảnh hồ sơ.', + 'AI Coach session deleted successfully.': + 'Đã xóa phiên AI Coach thành công.', + 'You do not have any active Premium program.': + 'Bạn không có chương trình Premium nào đang hoạt động.', + 'QuantityG or Quantity must be greater than 0.': 'Số lượng phải lớn hơn 0.', + 'You have already completed this quiz question.': + 'Bạn đã hoàn thành câu hỏi trắc nghiệm này.', + "You do not have access to this student's reports.": + 'Bạn không có quyền truy cập vào các báo cáo của học viên này.', + 'Height must be between 50cm and 300cm.': + 'Chiều cao phải từ 50cm đến 300cm.', + 'Coach data access revoked.': + 'Quyền truy cập dữ liệu của huấn luyện viên đã bị thu hồi.', + 'Worker response is empty.': 'Phản hồi từ Worker trống.', + 'AI service returned an empty status response.': + 'Dịch vụ AI trả về trạng thái trống.', + 'Your account has been successfully deactivated per Google Play privacy and data deletion policy.': + 'Tài khoản của bạn đã bị vô hiệu hóa thành công theo chính sách bảo mật của Google Play.', + 'Invalid SePay transaction id.': 'ID giao dịch SePay không hợp lệ.', + 'Allergy not found.': 'Không tìm thấy dữ liệu dị ứng.', + 'Requested custom unit not found.': + 'Không tìm thấy đơn vị tùy chỉnh yêu cầu.', + 'Activity log not found.': 'Không tìm thấy nhật ký hoạt động.', + 'Body fat percentage must be between 1% and 80%.': + 'Tỷ lệ mỡ cơ thể phải từ 1% đến 80%.', + 'Successfully disconnected from Coach.': + 'Đã ngắt kết nối thành công với Huấn luyện viên.', + 'Start date (from) cannot be greater than end date (to).': + 'Ngày bắt đầu không thể lớn hơn ngày kết thúc.', + 'Alert dismissed successfully.': 'Đã loại bỏ cảnh báo thành công.', + 'Certificate name, issuer and image are required.': + 'Tên chứng chỉ, tổ chức cấp và hình ảnh là bắt buộc.', + 'Removed from favorites successfully.': + 'Đã xóa khỏi mục yêu thích thành công.', + 'Provider order code is required.': + 'Mã đơn hàng của nhà cung cấp là bắt buộc.', + 'Student has not granted you access to their health data.': + 'Học viên chưa cấp cho bạn quyền truy cập vào dữ liệu sức khỏe của họ.', + 'You do not have permission to access this report.': + 'Bạn không có quyền truy cập vào báo cáo này.', + 'Connection request not found.': 'Không tìm thấy yêu cầu kết nối.', + 'Meal template has no items.': 'Mẫu bữa ăn không có mục nào.', + 'Alert acknowledged successfully.': 'Đã xác nhận cảnh báo thành công.', + 'At least one portfolio image is required.': + 'Cần ít nhất một hình ảnh hồ sơ năng lực.', + 'Google account does not have an email address.': + 'Tài khoản Google không có địa chỉ email.', + 'User does not exist.': 'Người dùng không tồn tại.', + 'Invalid operation.': 'Thao tác không hợp lệ.', + 'Connection request sent to Coach.': + 'Đã gửi yêu cầu kết nối đến Huấn luyện viên.', + 'Invalid or expired refresh token.': + 'Token làm mới không hợp lệ hoặc đã hết hạn.', + 'Target weight must be between 30kg and 300kg.': + 'Cân nặng mục tiêu phải từ 30kg đến 300kg.', + 'Account type is required.': 'Loại tài khoản là bắt buộc.', + 'Weekly report not found.': 'Không tìm thấy báo cáo hàng tuần.', + 'Invalid month (must be between 1 and 12).': + 'Tháng không hợp lệ (phải từ 1 đến 12).', + 'Weight is required.': 'Cân nặng là bắt buộc.', + 'This report was sent to another coach.': + 'Báo cáo này đã được gửi cho một huấn luyện viên khác.', + "Food applied to today's meal plan successfully.": + 'Đã áp dụng món ăn vào kế hoạch hôm nay thành công.', + 'Breakfast time must be in HH:mm format.': + 'Thời gian bữa sáng phải ở định dạng HH:mm.', + 'MealType must be Breakfast, Lunch, Dinner, or Snack.': + 'Loại bữa ăn phải là Sáng, Trưa, Tối hoặc Ăn vặt.', + 'Fat must be positive.': 'Chất béo (Fat) phải là số dương.', + 'Professional headline is required.': + 'Tiêu đề hồ sơ chuyên gia là bắt buộc.', + 'Paid subscriptions must use SePay. Subscribe: POST /api/payments/sepay/create-order. Renew: POST /api/payments/sepay/create-renew-order.': + 'Đăng ký trả phí phải sử dụng SePay.', + 'You have an active Premium program. Please complete your current program before starting a new one.': + 'Bạn có một chương trình Premium đang hoạt động. Vui lòng hoàn thành chương trình hiện tại trước khi bắt đầu chương trình mới.', + 'Review submitted. Student has been notified.': + 'Đã gửi đánh giá. Học viên đã được thông báo.', + 'Invalid SePay webhook payload.': 'Dữ liệu webhook SePay không hợp lệ.', + 'Lunch time must be in HH:mm format.': + 'Thời gian bữa trưa phải ở định dạng HH:mm.', + 'Email is already registered.': 'Email này đã được đăng ký.', + 'Each template item must reference Food/Recipe or contain an AI scan snapshot.': + 'Mỗi mục mẫu phải liên kết đến Món ăn/Công thức hoặc chứa ảnh AI Scan.', + 'Email does not exist.': 'Email không tồn tại.', + 'Date of birth is required.': 'Ngày sinh là bắt buộc.', + 'Firebase is not initialized. Check Firebase credential configuration.': + 'Firebase chưa được khởi tạo. Vui lòng kiểm tra cấu hình.', + 'Service fee is required.': 'Phí dịch vụ là bắt buộc.', + 'Subscription plan name already exists.': 'Tên gói đăng ký đã tồn tại.', + 'This request is not a mid-week or final weekly report.': + 'Đây không phải là báo cáo giữa tuần hoặc cuối tuần.', + 'Invalid or missing user identity in token.': + 'Danh tính người dùng trong token bị thiếu hoặc không hợp lệ.', + 'Amount must be greater than zero.': 'Số tiền phải lớn hơn 0.', + 'Payment order not found.': 'Không tìm thấy đơn hàng thanh toán.', + 'An identity verification image is required.': + 'Cần có hình ảnh xác minh danh tính.', + 'Job not found.': 'Không tìm thấy công việc.', + 'Please verify your OTP before logging in.': + 'Vui lòng xác minh mã OTP của bạn trước khi đăng nhập.', + 'Message not found.': 'Không tìm thấy tin nhắn.', + 'Original program details not found.': + 'Không tìm thấy chi tiết chương trình gốc.', + 'No budget configuration found.': 'Không tìm thấy cấu hình ngân sách.', + 'Feedback type is required.': 'Loại phản hồi là bắt buộc.', + 'Payment order not found by transfer content.': + 'Không tìm thấy đơn hàng thanh toán theo nội dung chuyển khoản.', + 'User id is invalid.': 'ID người dùng không hợp lệ.', + 'Invalid Google sign-in token.': 'Token đăng nhập Google không hợp lệ.', + 'Original ingredient not found.': 'Không tìm thấy nguyên liệu gốc.', + 'Action requires user confirmation.': + 'Thao tác yêu cầu sự xác nhận của người dùng.', + 'This action type is reserved for the next implementation phase.': + 'Loại thao tác này được dành riêng cho giai đoạn tiếp theo.', + 'Cannot create feedback event': 'Không thể tạo sự kiện phản hồi', + 'input_text and expected_output are required': + 'input_text và expected_output là bắt buộc', + 'Cannot create training sample': 'Không thể tạo mẫu dữ liệu huấn luyện', + 'Sample not found': 'Không tìm thấy mẫu dữ liệu', + }; + + /// Stable backend error codes. Use these instead of relying on the + /// (translatable) message string when the caller needs to branch on the + /// specific failure reason. + + static const _errorCodes = { + 'RECALIBRATE_NO_WEIGHT_DATA': + 'Vui lòng cập nhật cân nặng ít nhất 1 lần trong 7 ngày qua trước khi hiệu chỉnh mục tiêu.', + 'RECALIBRATE_ANOMALY_WEIGHT_CHANGE': + 'Thay đổi cân nặng tuần qua bất thường. Vui lòng kiểm tra lại các lần ghi cân nặng gần đây trước khi hiệu chỉnh mục tiêu.', + }; + + static final _macroExceeds = RegExp( + r'^(Protein|Carbs|Fat) exceeds target \(([0-9.]+)g / ([0-9.]+)g\)\.$', + ); + static final _macroBelow = RegExp( + r'^(Protein|Carbs|Fat) below target \(([0-9.]+)g / ([0-9.]+)g\)\.$', + ); + + // ---- Recalibration reason patterns ---- + // Backend GymGoalsController emits one of these as `reason`. They contain + // interpolated numbers, so we translate via regex capture groups. + static final _reasonCutReduced = RegExp( + r'^Goal is Cut but your weight increased or stayed the same \((-?[0-9.]+) kg\) over the past week\. Suggesting 10% reduction in calorie intake\.$', + ); + static final _reasonCutStable = RegExp( + r'^Good weight loss progress \((-?[0-9.]+) kg\)\. Continue maintaining current calorie level\.$', + ); + static final _reasonBulkIncreased = RegExp( + r'^Goal is Bulk but your weight decreased or stayed the same \((-?[0-9.]+) kg\) over the past week\. Suggesting 10% increase in calorie intake\.$', + ); + static final _reasonBulkStable = RegExp( + r'^Good weight gain progress \(\+(-?[0-9.]+) kg\)\. Continue maintaining current calorie level\.$', + ); + static final _reasonMaintainReduce = RegExp( + r'^Weight increased \((-?[0-9.]+) kg\) versus maintain goal\. Suggesting 5% calorie reduction\.$', + ); + static final _reasonMaintainIncrease = RegExp( + r'^Weight decreased \((-?[0-9.]+) kg\) versus maintain goal\. Suggesting 5% calorie increase\.$', + ); + static final _reasonOptimal = RegExp( + r'^Your target calorie intake is at an optimal level\.$', + ); + // Suffix appended after the main reason. Translates the relationship + // between current weight and the user's target weight. + static final _suffixWeightDistance = RegExp( + r' Current weight is (-?[0-9.]+) kg, ([0-9.]+) kg (above|below|at) the target\.', + ); + static final _suffixBodyFatDistance = RegExp( + r' Body fat is (-?[0-9.]+)%, ([0-9.]+) percentage points (above|below|at) the target\.', + ); + + static const _macroLabels = { + 'Protein': 'Protein', + 'Carbs': 'Carb', + 'Fat': 'Chất béo', + }; + + /// Translate the recalibration `reason` string from + /// `GymGoalsController.Recalibrate`. The backend composes a sentence of + /// the form + /// `[head]. Current weight is X kg, Y kg above the target. Body fat is Z%, W percentage points at the target.` + /// We translate the head, the optional weight-distance suffix, and the + /// optional body-fat suffix into Vietnamese. + static String? _translateRecalibrationReason(String text) { + // Pull off suffixes first (weight distance, then body-fat distance) and + // translate each independently so the order doesn't matter. + final parts = []; + var remaining = text; + + final bodyFatMatch = _suffixBodyFatDistance.firstMatch(remaining); + String? bodyFatSuffix; + if (bodyFatMatch != null) { + final pct = bodyFatMatch.group(1)!; + final diff = bodyFatMatch.group(2)!; + final pos = bodyFatMatch.group(3)!; + final posVi = switch (pos) { + 'above' => 'trên', + 'below' => 'dưới', + _ => 'đúng', + }; + bodyFatSuffix = + 'Tỷ lệ mỡ hiện tại $pct%, chênh $diff điểm phần trăm $posVi mục tiêu.'; + remaining = + remaining.substring(0, bodyFatMatch.start) + + remaining.substring(bodyFatMatch.end); + } + + final weightMatch = _suffixWeightDistance.firstMatch(remaining); + String? weightSuffix; + if (weightMatch != null) { + final kg = weightMatch.group(1)!; + final diff = weightMatch.group(2)!; + final pos = weightMatch.group(3)!; + final posVi = switch (pos) { + 'above' => 'trên', + 'below' => 'dưới', + _ => 'đúng', + }; + weightSuffix = + 'Cân nặng hiện tại $kg kg, chênh $diff kg $posVi mục tiêu.'; + remaining = + remaining.substring(0, weightMatch.start) + + remaining.substring(weightMatch.end); + } + + remaining = remaining.trim(); + String? head; + final m = _reasonCutReduced.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Mục tiêu là giảm cân nhưng cân nặng tăng hoặc giữ nguyên ($kg kg) trong tuần qua. Đề xuất giảm 10% calo.'; + } else { + final m = _reasonCutStable.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Tiến triển giảm cân tốt ($kg kg). Tiếp tục duy trì mức calo hiện tại.'; + } else { + final m = _reasonBulkIncreased.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Mục tiêu là tăng cân nhưng cân nặng giảm hoặc giữ nguyên ($kg kg) trong tuần qua. Đề xuất tăng 10% calo.'; + } else { + final m = _reasonBulkStable.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Tiến triển tăng cân tốt (+$kg kg). Tiếp tục duy trì mức calo hiện tại.'; + } else { + final m = _reasonMaintainReduce.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Cân nặng tăng ($kg kg) so với mục tiêu duy trì. Đề xuất giảm 5% calo.'; + } else { + final m = _reasonMaintainIncrease.firstMatch(remaining); + if (m != null) { + final kg = m.group(1)!; + head = + 'Cân nặng giảm ($kg kg) so với mục tiêu duy trì. Đề xuất tăng 5% calo.'; + } else if (_reasonOptimal.hasMatch(remaining)) { + head = 'Calo mục tiêu hiện tại đang ở mức tối ưu.'; + } + } + } + } + } + } + + if (head == null) return null; + if (weightSuffix != null) parts.add(weightSuffix); + if (bodyFatSuffix != null) parts.add(bodyFatSuffix); + return [head, ...parts].join(' '); + } + + /// Dịch một message; đảm bảo tuyệt đối KHÔNG bao giờ trả về chuỗi HTML, JSON hay mã lỗi stacktrace cho user. + /// + /// Khi [errorCode] được cung cấp (ví dụ `RECALIBRATE_NO_WEIGHT_DATA`), + /// nó được ưu tiên để map sang câu tiếng Việt ổn định, không phụ thuộc + /// vào message gốc của server (có thể thay đổi theo thời gian). + static String translate(String? message, {String? errorCode}) { + if (errorCode != null && errorCode.isNotEmpty) { + final fromCode = _errorCodes[errorCode]; + if (fromCode != null) return fromCode; + } + if (message == null) return ''; + var trimmed = message.trim(); + if (trimmed.isEmpty) return ''; + + // 1. Kiểm tra & lọc bỏ hoàn toàn nếu là HTML + if (_isHtml(trimmed)) { + return 'Máy chủ đang gặp sự cố tạm thời. Vui lòng thử lại sau.'; + } + + // 2. Loại bỏ các tiền tố Exception kỹ thuật + for (final prefix in [ + 'Exception: ', + 'System.Exception: ', + 'System.InvalidOperationException: ', + 'InvalidOperationException: ', + 'Bad Request: ', + 'DioException: ', + 'FormatException: ', + ]) { + if (trimmed.startsWith(prefix)) { + trimmed = trimmed.substring(prefix.length).trim(); + } + } + + // 3. Trích xuất nếu message có dạng JSON / ProblemDetails từ ASP.NET Core + final parsedJsonMessage = _tryParseJsonError(trimmed); + if (parsedJsonMessage != null && parsedJsonMessage.isNotEmpty) { + return parsedJsonMessage; + } + + // 4. Tra từ điển chính xác + final exact = _exact[trimmed] ?? _exact['$trimmed.']; + if (exact != null) return exact; + + // 5. Tra từ điển không phân biệt hoa thường + for (final entry in _exact.entries) { + if (entry.key.toLowerCase() == trimmed.toLowerCase()) { + return entry.value; + } + } + + // 6. Khớp Regex cho dinh dưỡng macro + final exceeds = _macroExceeds.firstMatch(trimmed); + if (exceeds != null) { + final label = _macroLabels[exceeds.group(1)] ?? exceeds.group(1)!; + return '$label vượt mục tiêu (${exceeds.group(2)}g / ${exceeds.group(3)}g).'; + } + + final below = _macroBelow.firstMatch(trimmed); + if (below != null) { + final label = _macroLabels[below.group(1)] ?? below.group(1)!; + return '$label thấp hơn mục tiêu (${below.group(2)}g / ${below.group(3)}g).'; + } + + // 6b. Recalibration reason (English from GymGoalsController) — strip + // any "current weight / body fat" suffix, translate the head, then + // append the translated suffix (if present). + final reason = _translateRecalibrationReason(trimmed); + if (reason != null) return reason; + + // 7. Giữ nguyên nếu đã là câu tiếng Việt hoàn chỉnh + if (_looksVietnamese(trimmed)) return trimmed; + + // 8. Lọc bỏ nếu chứa thuật ngữ kỹ thuật / StackTrace + if (_hasTechnicalTerms(trimmed)) { + return 'Hệ thống phản hồi không hợp lệ. Vui lòng thử lại sau.'; + } + + // 9. Fallback an toàn cho tiếng Anh chưa dịch: Trả về câu thông báo thân thiện + return 'Thao tác chưa hoàn thành. Vui lòng thử lại sau.'; + } + + static List translateList(List messages) { + return messages.map(translate).where((m) => m.isNotEmpty).toList(); + } + + /// Dịch title/body từ FCM push notification. + static String translateNotification(String? text) { + if (text == null) return ''; + final trimmed = text.trim(); + if (trimmed.isEmpty) return trimmed; + + final exact = _exact[trimmed]; + if (exact != null) return exact; + + final dynamic = _translateDynamicNotification(trimmed); + if (dynamic != null) return dynamic; + + if (_looksVietnamese(trimmed)) return trimmed; + + return translate(trimmed); + } + + static String? _translateDynamicNotification(String text) { + final connectionRequest = RegExp( + r'^(.+) wants to connect and hire you as their nutrition coach\.$', + ).firstMatch(text); + if (connectionRequest != null) { + return '${connectionRequest.group(1)} muốn kết nối và thuê bạn làm PT dinh dưỡng.'; + } + + final connectionResponse = RegExp( + r'^Coach (.+) has (accepted|rejected) your connection request\.$', + ).firstMatch(text); + if (connectionResponse != null) { + final action = connectionResponse.group(2) == 'accepted' + ? 'chấp nhận' + : 'từ chối'; + return 'PT ${connectionResponse.group(1)} đã $action yêu cầu kết nối của bạn.'; + } + + final feedback = RegExp( + r'^Coach (.+) sent you new feedback: "(.*)"$', + ).firstMatch(text); + if (feedback != null) { + return 'PT ${feedback.group(1)} đã gửi phản hồi mới: "${feedback.group(2)}"'; + } + + final mealPlan = RegExp( + r'^Coach (.+) has directly adjusted your meal plan\.$', + ).firstMatch(text); + if (mealPlan != null) { + return 'PT ${mealPlan.group(1)} đã trực tiếp điều chỉnh kế hoạch ăn uống của bạn.'; + } + + final targets = RegExp( + r'^Coach (.+) has updated your Calorie/Macros targets\.$', + ).firstMatch(text); + if (targets != null) { + return 'PT ${targets.group(1)} đã cập nhật mục tiêu calo và dinh dưỡng của bạn.'; + } + + return null; + } + + static bool _isHtml(String text) { + final lower = text.toLowerCase(); + return lower.contains(') { + if (decoded.containsKey('errors') && decoded['errors'] is Map) { + final errorsMap = decoded['errors'] as Map; + if (errorsMap.isNotEmpty) { + final firstValue = errorsMap.values.first; + if (firstValue is List && firstValue.isNotEmpty) { + return translate(firstValue.first.toString()); + } + return translate(firstValue.toString()); + } + } + final msg = + decoded['message'] ?? + decoded['Message'] ?? + decoded['title'] ?? + decoded['Title'] ?? + decoded['detail'] ?? + decoded['Detail']; + if (msg != null && msg.toString().isNotEmpty) { + return translate(msg.toString()); + } + } + } catch (_) {} + return null; + } + + static bool _hasTechnicalTerms(String text) { + final lower = text.toLowerCase(); + return lower.contains('exception') || + lower.contains('socketexception') || + lower.contains('formatexception') || + lower.contains('dioexception') || + lower.contains('nullreference') || + lower.contains('invalidoperation') || + lower.contains('stacktrace') || + lower.contains('at system.') || + lower.contains('http.response') || + lower.contains('typeerror') || + lower.contains('nosuchmethod') || + lower.contains('bad request') || + lower.contains('internal server error') || + lower.contains('application/problem+json'); + } + + static bool _looksVietnamese(String text) { + return RegExp( + r'[àáảãạăằắẳẵặâầấẩẫậèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđ]', + caseSensitive: false, + ).hasMatch(text); + } +} + +/// Tiện ích mở rộng giúp dễ dàng dịch data payload trên UI +extension DataTranslationExtension on String { + String get translatedData => ApiMessageTranslator.translateData(this); +} diff --git a/frontend/lib/core/middleware/error_translator.dart b/frontend/lib/core/middleware/error_translator.dart index 5435750..8b286c0 100644 --- a/frontend/lib/core/middleware/error_translator.dart +++ b/frontend/lib/core/middleware/error_translator.dart @@ -1,4 +1,4 @@ -import '../i18n/api_message_translator.dart'; +import '../i18n/api_message_translator_fixed.dart'; class ErrorTranslator { ErrorTranslator._(); diff --git a/frontend/lib/core/services/notification_handler.dart b/frontend/lib/core/services/notification_handler.dart index 4830fac..190719f 100644 --- a/frontend/lib/core/services/notification_handler.dart +++ b/frontend/lib/core/services/notification_handler.dart @@ -12,7 +12,7 @@ import '../../features/advanced/views/advanced_features_screen.dart'; import '../../features/gymer/views/premium_programs_screen.dart'; import '../../features/coach_chat/views/coach_chat_screen.dart'; import '../../features/notifications/models/notification_models.dart'; -import '../../core/i18n/api_message_translator.dart'; +import '../../core/i18n/api_message_translator_fixed.dart'; enum NotificationActionType { openApp, @@ -460,7 +460,7 @@ class NotificationHandler { ], ), behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 4), + duration: const Duration(seconds: 5), action: SnackBarAction( label: 'Xem', onPressed: () => handleNotificationTap(context, message), diff --git a/frontend/lib/features/ai_assistant/repositories/ai_assistant_repository.dart b/frontend/lib/features/ai_assistant/repositories/ai_assistant_repository.dart index bef0e16..799f409 100644 --- a/frontend/lib/features/ai_assistant/repositories/ai_assistant_repository.dart +++ b/frontend/lib/features/ai_assistant/repositories/ai_assistant_repository.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import '../../../../core/i18n/api_message_translator.dart'; +import '../../../../core/i18n/api_message_translator_fixed.dart'; import '../../../../core/network/api_client.dart'; import '../../../../core/network/api_endpoints.dart'; import '../models/ai_assistant_models.dart'; diff --git a/frontend/lib/features/coach/views/coach_application_screen.dart b/frontend/lib/features/coach/views/coach_application_screen.dart index 10e7980..7ea259d 100644 --- a/frontend/lib/features/coach/views/coach_application_screen.dart +++ b/frontend/lib/features/coach/views/coach_application_screen.dart @@ -404,9 +404,9 @@ class _CoachApplicationScreenState extends State { void _showMessage(String message) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(duration: const Duration(seconds: 5), content: Text(message)), + ); } void _showError(Object error) { diff --git a/frontend/lib/features/coach/views/coach_application_status_screen.dart b/frontend/lib/features/coach/views/coach_application_status_screen.dart index 44fe108..1d446a8 100644 --- a/frontend/lib/features/coach/views/coach_application_status_screen.dart +++ b/frontend/lib/features/coach/views/coach_application_status_screen.dart @@ -49,6 +49,7 @@ class _CoachApplicationStatusScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(error.toString().replaceFirst('Exception: ', '')), ), ); diff --git a/frontend/lib/features/coach/views/coach_main_screen.dart b/frontend/lib/features/coach/views/coach_main_screen.dart index 43753fe..e584547 100644 --- a/frontend/lib/features/coach/views/coach_main_screen.dart +++ b/frontend/lib/features/coach/views/coach_main_screen.dart @@ -406,9 +406,12 @@ class _CoachClientsTabState extends State<_CoachClientsTab> { await _load(); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(e.toString()))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text(e.toString()), + ), + ); } } } @@ -1467,7 +1470,7 @@ class _CoachNotificationsTabState extends State<_CoachNotificationsTab> messenger.showSnackBar( const SnackBar( content: Text('Đã đánh dấu tất cả là đã đọc'), - duration: Duration(seconds: 2), + duration: Duration(seconds: 5), ), ); } catch (e) { @@ -1479,7 +1482,7 @@ class _CoachNotificationsTabState extends State<_CoachNotificationsTab> SnackBar( content: Text('Không thể đánh dấu: $e'), backgroundColor: Colors.red, - duration: const Duration(seconds: 3), + duration: const Duration(seconds: 5), ), ); } diff --git a/frontend/lib/features/coach/views/coach_profile_edit_screen.dart b/frontend/lib/features/coach/views/coach_profile_edit_screen.dart index 06a0868..658c845 100644 --- a/frontend/lib/features/coach/views/coach_profile_edit_screen.dart +++ b/frontend/lib/features/coach/views/coach_profile_edit_screen.dart @@ -84,7 +84,8 @@ class _CoachProfileEditScreenState extends State { final now = DateTime.now(); final picked = await showSafeDatePicker( context: context, - initialDate: _dateOfBirth ?? DateTime(now.year - 25, now.month, now.day), + initialDate: + _dateOfBirth ?? DateTime(now.year - 25, now.month, now.day), firstDate: DateTime(1950), lastDate: now, helpText: 'Chọn ngày sinh', @@ -111,10 +112,12 @@ class _CoachProfileEditScreenState extends State { Future _handlePickAndUploadAvatar() async { await FirebaseBootstrap.initialize(); - if (!FirebaseBootstrap.isInitialized || !FirebaseStorageService.isSupported) { + if (!FirebaseBootstrap.isInitialized || + !FirebaseStorageService.isSupported) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text( 'Upload ảnh chỉ hỗ trợ trên Android/iOS. Hãy chạy app trên emulator hoặc điện thoại.', ), @@ -129,7 +132,10 @@ class _CoachProfileEditScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Không xác định được tài khoản. Vui lòng đăng nhập lại.'), + duration: Duration(seconds: 5), + content: Text( + 'Không xác định được tài khoản. Vui lòng đăng nhập lại.', + ), backgroundColor: Colors.red, ), ); @@ -157,6 +163,7 @@ class _CoachProfileEditScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text('Cập nhật avatar thành công!'), backgroundColor: Colors.green, ), @@ -164,6 +171,7 @@ class _CoachProfileEditScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text('Lưu avatar lên server thất bại.'), backgroundColor: Colors.red, ), @@ -173,6 +181,7 @@ class _CoachProfileEditScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(localizeFirebaseStorageError(e)), backgroundColor: Colors.red, ), @@ -199,12 +208,20 @@ class _CoachProfileEditScreenState extends State { if (result != null) { setState(() => _profileChanged = true); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cập nhật thành công!'), backgroundColor: Colors.green), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Cập nhật thành công!'), + backgroundColor: Colors.green, + ), ); Navigator.pop(context, true); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Có lỗi xảy ra, vui lòng thử lại!'), backgroundColor: Colors.red), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Có lỗi xảy ra, vui lòng thử lại!'), + backgroundColor: Colors.red, + ), ); } } @@ -240,43 +257,54 @@ class _CoachProfileEditScreenState extends State { centerTitle: true, title: const Text( 'Chỉnh sửa hồ sơ', - style: TextStyle(color: AppColors.textDark, fontSize: 18, fontWeight: FontWeight.bold), + style: TextStyle( + color: AppColors.textDark, + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), ), body: _isLoading - ? const Center(child: CircularProgressIndicator(color: AppColors.primary)) + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) : MediaQuery.removeViewInsets( context: context, removeBottom: true, child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAvatarSection(), - const SizedBox(height: 24), - CustomTextField( - controller: _fullNameController, - label: 'Họ và tên', - hintText: 'Nhập họ tên', - ), - const SizedBox(height: 20), - _buildGenderDropdown(), - const SizedBox(height: 20), - _buildDateOfBirthField(), - const SizedBox(height: 40), - _isSaving - ? const Center(child: CircularProgressIndicator(color: AppColors.primary)) - : PrimaryButton( - text: 'Lưu thay đổi', - onPressed: _handleSave, - ), - const SizedBox(height: 280), - ], + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatarSection(), + const SizedBox(height: 24), + CustomTextField( + controller: _fullNameController, + label: 'Họ và tên', + hintText: 'Nhập họ tên', + ), + const SizedBox(height: 20), + _buildGenderDropdown(), + const SizedBox(height: 20), + _buildDateOfBirthField(), + const SizedBox(height: 40), + _isSaving + ? const Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ) + : PrimaryButton( + text: 'Lưu thay đổi', + onPressed: _handleSave, + ), + const SizedBox(height: 280), + ], + ), ), ), - ), ), ); } @@ -287,7 +315,11 @@ class _CoachProfileEditScreenState extends State { children: [ const Text( 'Ngày sinh', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textDark), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textDark, + ), ), const SizedBox(height: 8), InkWell( @@ -298,7 +330,10 @@ class _CoachProfileEditScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration( color: Colors.white, - border: Border.all(color: AppColors.progressBackground, width: 1.5), + border: Border.all( + color: AppColors.progressBackground, + width: 1.5, + ), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -307,11 +342,17 @@ class _CoachProfileEditScreenState extends State { Text( _dateOfBirthLabel(), style: TextStyle( - color: _dateOfBirth == null ? AppColors.textSecondary : AppColors.textDark, + color: _dateOfBirth == null + ? AppColors.textSecondary + : AppColors.textDark, fontSize: 14, ), ), - const Icon(Icons.calendar_today_outlined, color: AppColors.textSecondary, size: 20), + const Icon( + Icons.calendar_today_outlined, + color: AppColors.textSecondary, + size: 20, + ), ], ), ), @@ -321,18 +362,18 @@ class _CoachProfileEditScreenState extends State { } Widget _buildGenderDropdown() { - final genderLabels = { - 'Male': 'Nam', - 'Female': 'Nữ', - 'Other': 'Khác', - }; + final genderLabels = {'Male': 'Nam', 'Female': 'Nữ', 'Other': 'Khác'}; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Giới tính', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textDark), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textDark, + ), ), const SizedBox(height: 8), Container( @@ -346,12 +387,24 @@ class _CoachProfileEditScreenState extends State { child: DropdownButton( isExpanded: true, value: _gender, - hint: const Text('Chưa chọn', style: TextStyle(color: AppColors.textSecondary, fontSize: 14)), - icon: const Icon(Icons.keyboard_arrow_down, color: AppColors.textSecondary), + hint: const Text( + 'Chưa chọn', + style: TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + icon: const Icon( + Icons.keyboard_arrow_down, + color: AppColors.textSecondary, + ), items: genderLabels.entries.map((e) { return DropdownMenuItem( value: e.key, - child: Text(e.value, style: const TextStyle(color: AppColors.textDark, fontSize: 14)), + child: Text( + e.value, + style: const TextStyle( + color: AppColors.textDark, + fontSize: 14, + ), + ), ); }).toList(), onChanged: (val) => setState(() => _gender = val), @@ -375,7 +428,11 @@ class _CoachProfileEditScreenState extends State { backgroundColor: AppColors.progressBackground, backgroundImage: hasAvatar ? NetworkImage(_avatarUrl!) : null, child: !hasAvatar - ? const Icon(Icons.person, size: 48, color: AppColors.textSecondary) + ? const Icon( + Icons.person, + size: 48, + color: AppColors.textSecondary, + ) : null, ), if (_isAvatarSaving) @@ -386,7 +443,10 @@ class _CoachProfileEditScreenState extends State { shape: BoxShape.circle, ), child: const Center( - child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), ), ), ), @@ -405,7 +465,8 @@ class _CoachProfileEditScreenState extends State { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: (_isAvatarSaving || !FirebaseStorageService.isSupported) + onPressed: + (_isAvatarSaving || !FirebaseStorageService.isSupported) ? null : _handlePickAndUploadAvatar, icon: const Icon(Icons.photo_library_outlined, size: 20), @@ -422,4 +483,4 @@ class _CoachProfileEditScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/frontend/lib/features/coach_chat/views/coach_chat_screen.dart b/frontend/lib/features/coach_chat/views/coach_chat_screen.dart index 335becc..e7d54ba 100644 --- a/frontend/lib/features/coach_chat/views/coach_chat_screen.dart +++ b/frontend/lib/features/coach_chat/views/coach_chat_screen.dart @@ -54,7 +54,10 @@ class _CoachChatScreenState extends State { } else { final error = context.read().error; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error ?? 'Không thể gửi tin nhắn.')), + SnackBar( + duration: const Duration(seconds: 5), + content: Text(error ?? 'Không thể gửi tin nhắn.'), + ), ); } } @@ -148,7 +151,10 @@ class _CoachChatScreenState extends State { ), const SizedBox(width: 5), Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1.5, + ), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -190,7 +196,9 @@ class _CoachChatScreenState extends State { Expanded( child: provider.loadingMessages ? const Center( - child: CircularProgressIndicator(color: AppColors.primary), + child: CircularProgressIndicator( + color: AppColors.primary, + ), ) : provider.error != null && provider.messages.isEmpty ? _ErrorState( @@ -372,7 +380,9 @@ class _MessageBubble extends StatelessWidget { '${message.sentAt.minute.toString().padLeft(2, '0')}', style: TextStyle( fontSize: 10.5, - color: mine ? Colors.white.withValues(alpha: 0.8) : AppColors.textSecondary, + color: mine + ? Colors.white.withValues(alpha: 0.8) + : AppColors.textSecondary, ), ), if (mine) ...[ @@ -491,11 +501,17 @@ class _ErrorState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(message, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textDark)), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textDark), + ), const SizedBox(height: 12), OutlinedButton( onPressed: onRetry, - style: OutlinedButton.styleFrom(foregroundColor: AppColors.primary), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + ), child: const Text('Thử lại'), ), ], diff --git a/frontend/lib/features/coach_pt/views/coach_create_meal_plan_screen.dart b/frontend/lib/features/coach_pt/views/coach_create_meal_plan_screen.dart index cc735d8..f9c47cb 100644 --- a/frontend/lib/features/coach_pt/views/coach_create_meal_plan_screen.dart +++ b/frontend/lib/features/coach_pt/views/coach_create_meal_plan_screen.dart @@ -269,6 +269,7 @@ class _CoachCreateMealPlanScreenState extends State { if (ok) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text('Đã lưu cấu hình và danh sách món thành công!'), ), ); @@ -276,6 +277,7 @@ class _CoachCreateMealPlanScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text('Lưu bản nháp thất bại. Vui lòng thử lại.'), ), ); @@ -1451,6 +1453,7 @@ class _CoachCreateMealPlanScreenState extends State { ..hideCurrentSnackBar() ..showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text( 'Đã $action khẩu phần khoảng $percent% cho ngày ${_fmt(date)} ' 'về $desiredCalories kcal ($targetComparison $target kcal).', @@ -1754,6 +1757,7 @@ class _CoachCreateMealPlanScreenState extends State { if (mounted && _suggestionsError == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text( 'Không có món nào nằm trong khoảng kcal đã cấu hình.', ), @@ -1785,6 +1789,7 @@ class _CoachCreateMealPlanScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text( 'Đã khởi tạo lộ trình. PT có thể thêm, thay thế hoặc xóa món trước khi lưu.', ), @@ -2210,7 +2215,10 @@ class _CoachCreateMealPlanScreenState extends State { if (preview.quantityG <= 0 || quantities.any((value) => value <= 0)) { ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar(content: Text('Khối lượng phải lớn hơn 0.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Khối lượng phải lớn hơn 0.'), + ), ); return; } diff --git a/frontend/lib/features/coach_pt/views/coach_meal_plan_detail_screen.dart b/frontend/lib/features/coach_pt/views/coach_meal_plan_detail_screen.dart index 1147aab..d9b75e7 100644 --- a/frontend/lib/features/coach_pt/views/coach_meal_plan_detail_screen.dart +++ b/frontend/lib/features/coach_pt/views/coach_meal_plan_detail_screen.dart @@ -283,6 +283,7 @@ class _CoachMealPlanDetailScreenState extends State ..hideCurrentSnackBar() ..showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text( 'Đã $action khẩu phần khoảng $percent% về $desiredCalories kcal ' '($targetComparison $target kcal). Hãy lưu hoặc duyệt để áp dụng.', @@ -310,12 +311,18 @@ class _CoachMealPlanDetailScreenState extends State if (!mounted) return false; if (ok) { setState(() => _dirty = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Đã lưu nháp lộ trình.'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Đã lưu nháp lộ trình.'), + ), + ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Lưu thất bại. Vui lòng thử lại.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Lưu thất bại. Vui lòng thử lại.'), + ), ); } return ok; @@ -343,6 +350,7 @@ class _CoachMealPlanDetailScreenState extends State ..hideCurrentSnackBar() ..showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text( message == null || message.isEmpty ? 'Gửi thất bại. Vui lòng thử lại.' diff --git a/frontend/lib/features/coach_pt/views/coach_meal_plan_history_screen.dart b/frontend/lib/features/coach_pt/views/coach_meal_plan_history_screen.dart index 70152ea..a1b3463 100644 --- a/frontend/lib/features/coach_pt/views/coach_meal_plan_history_screen.dart +++ b/frontend/lib/features/coach_pt/views/coach_meal_plan_history_screen.dart @@ -457,6 +457,7 @@ class _PlanList extends StatelessWidget { ..hideCurrentSnackBar() ..showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text( 'Đã gửi lộ trình. Gymer sẽ nhận thông báo để xem và chấp nhận.', ), diff --git a/frontend/lib/features/coach_pt/views/coach_report_detail_screen.dart b/frontend/lib/features/coach_pt/views/coach_report_detail_screen.dart index 8c10589..8b07798 100644 --- a/frontend/lib/features/coach_pt/views/coach_report_detail_screen.dart +++ b/frontend/lib/features/coach_pt/views/coach_report_detail_screen.dart @@ -52,9 +52,12 @@ class _CoachReportDetailScreenState extends State { Future _submit() async { if (_commentCtrl.text.trim().isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Vui lòng nhập nhận xét.'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Vui lòng nhập nhận xét.'), + ), + ); return; } setState(() => _submitting = true); @@ -72,6 +75,7 @@ class _CoachReportDetailScreenState extends State { setState(() => _submitting = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text( ok ? 'Đã gửi đánh giá. Học viên sẽ nhận thông báo.' @@ -211,6 +215,7 @@ class _CoachReportDetailScreenState extends State { if (rawClientId == null || rawClientId.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( + duration: Duration(seconds: 5), content: Text( 'Không tìm thấy thông tin Gymer (clientId) để tạo lộ trình.', ), @@ -2288,25 +2293,37 @@ class _MealAdjustmentDialogState extends State<_MealAdjustmentDialog> { void _submit() { if ((_action == 'replace' || _action == 'remove') && _source == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Hãy chọn món đang có trong lộ trình.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Hãy chọn món đang có trong lộ trình.'), + ), ); return; } if ((_action == 'add' || _action == 'replace') && _candidate == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Hãy chọn món mới.'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Hãy chọn món mới.'), + ), + ); return; } if (_loadingIngredients) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Đang tải định lượng nguyên liệu.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Đang tải định lượng nguyên liệu.'), + ), ); return; } if (_ingredients.any((item) => item.quantity <= 0)) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Định lượng nguyên liệu phải lớn hơn 0.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Định lượng nguyên liệu phải lớn hơn 0.'), + ), ); return; } diff --git a/frontend/lib/features/discover/views/discover_view.dart b/frontend/lib/features/discover/views/discover_view.dart index ee14d5a..2b648f9 100644 --- a/frontend/lib/features/discover/views/discover_view.dart +++ b/frontend/lib/features/discover/views/discover_view.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/services/location_service.dart'; import '../../onboarding/repositories/user_ai_profile_repository.dart'; import '../../profile/views/allergies_screen.dart'; diff --git a/frontend/lib/features/discover/views/favorites_screen.dart b/frontend/lib/features/discover/views/favorites_screen.dart index dce7e22..c085936 100644 --- a/frontend/lib/features/discover/views/favorites_screen.dart +++ b/frontend/lib/features/discover/views/favorites_screen.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_text_styles.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/food_models.dart'; import '../providers/favorite_food_provider.dart'; import 'food_detail_screen.dart'; diff --git a/frontend/lib/features/discover/views/food_detail_screen.dart b/frontend/lib/features/discover/views/food_detail_screen.dart index 3cb6857..1a3ad43 100644 --- a/frontend/lib/features/discover/views/food_detail_screen.dart +++ b/frontend/lib/features/discover/views/food_detail_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/nutrition_format.dart'; import '../models/food_models.dart'; import '../providers/favorite_food_provider.dart'; diff --git a/frontend/lib/features/discover/views/ingredient_detail_screen.dart b/frontend/lib/features/discover/views/ingredient_detail_screen.dart index 4de6320..a67c599 100644 --- a/frontend/lib/features/discover/views/ingredient_detail_screen.dart +++ b/frontend/lib/features/discover/views/ingredient_detail_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_text_styles.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/nutrition_format.dart'; import '../models/food_models.dart'; import '../repositories/food_discovery_repository.dart'; diff --git a/frontend/lib/features/gymer/views/personal_program_detail_screen.dart b/frontend/lib/features/gymer/views/personal_program_detail_screen.dart index 2a43f03..de7a7f8 100644 --- a/frontend/lib/features/gymer/views/personal_program_detail_screen.dart +++ b/frontend/lib/features/gymer/views/personal_program_detail_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/meal_schedule_format.dart'; import '../../../core/utils/nutrition_format.dart'; import '../../advanced/repositories/advanced_repository.dart'; @@ -131,6 +131,7 @@ class _PersonalProgramDetailScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(ApiMessageTranslator.translate(error.toString())), ), ); @@ -150,6 +151,7 @@ class _PersonalProgramDetailScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(ApiMessageTranslator.translate(error.toString())), ), ); @@ -170,6 +172,7 @@ class _PersonalProgramDetailScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(ApiMessageTranslator.translate(error.toString())), ), ); @@ -251,7 +254,8 @@ class _PersonalProgramDetailScreenState crossAxisAlignment: CrossAxisAlignment.start, children: [ _Row('Mô tả', () { - String descText = p['description']?.toString() ?? '(không có)'; + String descText = + p['description']?.toString() ?? '(không có)'; final pattern = RegExp( r'từ\s+(\d{2}/\d{2}/\d{4})\s+đến\s+(\d{2}/\d{2}/\d{4})', caseSensitive: false, diff --git a/frontend/lib/features/gymer/views/premium_programs_screen.dart b/frontend/lib/features/gymer/views/premium_programs_screen.dart index 6ed3b21..84e2ddd 100644 --- a/frontend/lib/features/gymer/views/premium_programs_screen.dart +++ b/frontend/lib/features/gymer/views/premium_programs_screen.dart @@ -1769,6 +1769,7 @@ class _PremiumProgramsScreenState extends State { void _showMessage(String message, {bool error = false}) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( + duration: const Duration(seconds: 5), content: Text(message), backgroundColor: error ? Colors.red.shade700 : AppColors.primary, ), @@ -1898,7 +1899,10 @@ class _ProgramPaymentSheet extends StatelessWidget { await Clipboard.setData(ClipboardData(text: value)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Đã sao chép nội dung chuyển khoản.')), + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Đã sao chép nội dung chuyển khoản.'), + ), ); } @@ -2259,15 +2263,21 @@ class _SentRouteTabState extends State<_SentRouteTab> { try { await widget.advancedRepository.ptAction(id, 'apply'); if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Đã áp dụng gợi ý từ PT.'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Đã áp dụng gợi ý từ PT.'), + ), + ); await _load(); } catch (error) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(_cleanError(error)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text(_cleanError(error)), + ), + ); } finally { if (mounted) setState(() => _actionLoading = false); } diff --git a/frontend/lib/features/gymer/views/route_approval_detail_screen.dart b/frontend/lib/features/gymer/views/route_approval_detail_screen.dart index 9e3cd9c..685fbfa 100644 --- a/frontend/lib/features/gymer/views/route_approval_detail_screen.dart +++ b/frontend/lib/features/gymer/views/route_approval_detail_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/meal_schedule_format.dart'; import '../../../core/utils/nutrition_format.dart'; @@ -335,9 +335,12 @@ class _RouteApprovalDetailScreenState extends State { }); } catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(_cleanError(error)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text(_cleanError(error)), + ), + ); } } finally { if (mounted) { diff --git a/frontend/lib/features/gymer/widgets/route_approval_card.dart b/frontend/lib/features/gymer/widgets/route_approval_card.dart index 9da325e..4720016 100644 --- a/frontend/lib/features/gymer/widgets/route_approval_card.dart +++ b/frontend/lib/features/gymer/widgets/route_approval_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../utils/personal_program_period.dart'; import '../utils/route_approval_period.dart'; diff --git a/frontend/lib/features/home/widgets/weight_log_sheet.dart b/frontend/lib/features/home/widgets/weight_log_sheet.dart index 2a9c8a4..52a2b57 100644 --- a/frontend/lib/features/home/widgets/weight_log_sheet.dart +++ b/frontend/lib/features/home/widgets/weight_log_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../tracking/models/latest_weight_log.dart'; import '../../tracking/repositories/nutrition_tracking_repository.dart'; diff --git a/frontend/lib/features/meal_plan/providers/meal_plan_provider.dart b/frontend/lib/features/meal_plan/providers/meal_plan_provider.dart index e2d80c3..3913865 100644 --- a/frontend/lib/features/meal_plan/providers/meal_plan_provider.dart +++ b/frontend/lib/features/meal_plan/providers/meal_plan_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/meal_plan_requests.dart'; import '../models/meal_plan_responses.dart'; import '../repositories/meal_plan_repository.dart'; diff --git a/frontend/lib/features/meal_plan/repositories/meal_plan_repository.dart b/frontend/lib/features/meal_plan/repositories/meal_plan_repository.dart index 5e1cd41..be82b11 100644 --- a/frontend/lib/features/meal_plan/repositories/meal_plan_repository.dart +++ b/frontend/lib/features/meal_plan/repositories/meal_plan_repository.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; import '../models/meal_plan_models.dart'; @@ -300,6 +300,7 @@ class MealPlanRepository { if (response.statusCode != 200) { throw Exception(_messageFromResponse(response)); } + _invalidateCache(); } /// Cập nhật trạng thái meal plan @@ -406,6 +407,7 @@ class MealPlanRepository { if (response.statusCode != 200) { throw Exception(_messageFromResponse(response)); } + _invalidateCache(); } /// Cập nhật trạng thái item diff --git a/frontend/lib/features/meal_plan/views/meal_plan_detail_screen.dart b/frontend/lib/features/meal_plan/views/meal_plan_detail_screen.dart index f5371c8..865984c 100644 --- a/frontend/lib/features/meal_plan/views/meal_plan_detail_screen.dart +++ b/frontend/lib/features/meal_plan/views/meal_plan_detail_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/meal_plan_requests.dart'; import '../models/meal_plan_responses.dart'; import '../providers/meal_plan_provider.dart'; diff --git a/frontend/lib/features/meal_plan/views/meal_plan_screen.dart b/frontend/lib/features/meal_plan/views/meal_plan_screen.dart index bc42f7f..9994982 100644 --- a/frontend/lib/features/meal_plan/views/meal_plan_screen.dart +++ b/frontend/lib/features/meal_plan/views/meal_plan_screen.dart @@ -583,14 +583,23 @@ class _TodayTab extends StatelessWidget { ); if (confirmed == true && context.mounted) { - final success = await provider.deleteItem(item.mealPlanId ?? '', item.id); + final planId = item.mealPlanId ?? provider.plans.firstOrNull?.id; + if (planId == null || planId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Không tìm thấy kế hoạch chứa món ăn.')), + ); + return; + } + + final success = await provider.deleteItem(planId, item.id); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( success ? 'Đã xóa món "${item.displayName}" khỏi kế hoạch thành công.' - : 'Không thể xóa món ăn khỏi kế hoạch. Vui lòng thử lại.', + : provider.error ?? + 'Không thể xóa món ăn khỏi kế hoạch. Vui lòng thử lại.', ), ), ); diff --git a/frontend/lib/features/meal_plan/views/meal_plan_today_screen.dart b/frontend/lib/features/meal_plan/views/meal_plan_today_screen.dart index cc9f34d..dd88a32 100644 --- a/frontend/lib/features/meal_plan/views/meal_plan_today_screen.dart +++ b/frontend/lib/features/meal_plan/views/meal_plan_today_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/nutrition_format.dart'; import '../../discover/views/food_detail_screen.dart'; import '../../discover/views/recipe_detail_screen.dart'; diff --git a/frontend/lib/features/notifications/models/notification_models.dart b/frontend/lib/features/notifications/models/notification_models.dart index 7064944..f54da06 100644 --- a/frontend/lib/features/notifications/models/notification_models.dart +++ b/frontend/lib/features/notifications/models/notification_models.dart @@ -1,4 +1,4 @@ -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; enum NotificationType { mealReminder, diff --git a/frontend/lib/features/profile/views/change_password_screen.dart b/frontend/lib/features/profile/views/change_password_screen.dart index 3b91df1..515e492 100644 --- a/frontend/lib/features/profile/views/change_password_screen.dart +++ b/frontend/lib/features/profile/views/change_password_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/widgets/custom_text_field.dart'; import '../../../core/widgets/primary_button.dart'; diff --git a/frontend/lib/features/profile/views/profile_view.dart b/frontend/lib/features/profile/views/profile_view.dart index 523701b..175da4e 100644 --- a/frontend/lib/features/profile/views/profile_view.dart +++ b/frontend/lib/features/profile/views/profile_view.dart @@ -4,8 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; -import '../../../core/services/token_storage.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../casual/views/casual_hub_screen.dart'; import '../repositories/profile_repository.dart'; import '../../auth/views/welcome_screen.dart'; diff --git a/frontend/lib/features/subscription/repositories/user_subscription_repository.dart b/frontend/lib/features/subscription/repositories/user_subscription_repository.dart index 1ab21f6..18560f5 100644 --- a/frontend/lib/features/subscription/repositories/user_subscription_repository.dart +++ b/frontend/lib/features/subscription/repositories/user_subscription_repository.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; import '../../../core/network/jwt_utils.dart'; diff --git a/frontend/lib/features/subscription/views/upgrade_plan_screen.dart b/frontend/lib/features/subscription/views/upgrade_plan_screen.dart index d35a4f4..1f8f001 100644 --- a/frontend/lib/features/subscription/views/upgrade_plan_screen.dart +++ b/frontend/lib/features/subscription/views/upgrade_plan_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/network/token_storage.dart'; import '../../auth/views/login_screen.dart'; import '../../casual/views/casual_hub_screen.dart'; diff --git a/frontend/lib/features/tracking/utils/nutrition_warning_utils.dart b/frontend/lib/features/tracking/utils/nutrition_warning_utils.dart index 3b5fdc9..77fe29b 100644 --- a/frontend/lib/features/tracking/utils/nutrition_warning_utils.dart +++ b/frontend/lib/features/tracking/utils/nutrition_warning_utils.dart @@ -1,4 +1,4 @@ -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/nutrition_models.dart'; /// Ngưỡng lệch macro so với mục tiêu (15%). diff --git a/frontend/lib/features/tracking/widgets/suggested_dish_detail_sheet.dart b/frontend/lib/features/tracking/widgets/suggested_dish_detail_sheet.dart index e1e2d1f..2cf880f 100644 --- a/frontend/lib/features/tracking/widgets/suggested_dish_detail_sheet.dart +++ b/frontend/lib/features/tracking/widgets/suggested_dish_detail_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/cv_suggested_dish.dart'; typedef SuggestedDishAction = Future Function( diff --git a/frontend/lib/features/vietnam_local/repositories/vietnam_local_repositories.dart b/frontend/lib/features/vietnam_local/repositories/vietnam_local_repositories.dart index 9236ccd..31d2e8c 100644 --- a/frontend/lib/features/vietnam_local/repositories/vietnam_local_repositories.dart +++ b/frontend/lib/features/vietnam_local/repositories/vietnam_local_repositories.dart @@ -5,7 +5,7 @@ // surfaced via `_apiCall` so callers can display translated messages. import 'dart:convert'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/middleware/error_middleware.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; diff --git a/frontend/lib/features/vietnam_local/views/food_capture_screen.dart b/frontend/lib/features/vietnam_local/views/food_capture_screen.dart index f7afbf1..ee5a506 100644 --- a/frontend/lib/features/vietnam_local/views/food_capture_screen.dart +++ b/frontend/lib/features/vietnam_local/views/food_capture_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../providers/food_capture_provider.dart'; import '../widgets/info_card.dart'; import '../widgets/section_header.dart'; diff --git a/frontend/lib/features/vietnam_local/views/gym_goals_screen.dart b/frontend/lib/features/vietnam_local/views/gym_goals_screen.dart index dcb07e9..5bed90b 100644 --- a/frontend/lib/features/vietnam_local/views/gym_goals_screen.dart +++ b/frontend/lib/features/vietnam_local/views/gym_goals_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/utils/meal_schedule_format.dart'; import '../../../core/utils/nutrition_format.dart'; import '../../../core/widgets/calorie_adjustment_picker.dart'; diff --git a/frontend/lib/features/vietnam_local/views/ingredient_substitution_screen.dart b/frontend/lib/features/vietnam_local/views/ingredient_substitution_screen.dart index 6db3871..07e798f 100644 --- a/frontend/lib/features/vietnam_local/views/ingredient_substitution_screen.dart +++ b/frontend/lib/features/vietnam_local/views/ingredient_substitution_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/vietnam_local_models.dart'; import '../providers/ingredient_substitution_provider.dart'; import '../widgets/info_card.dart'; diff --git a/frontend/lib/features/vietnam_local/views/report_issue_screen.dart b/frontend/lib/features/vietnam_local/views/report_issue_screen.dart index d46fcec..d8b8bbc 100644 --- a/frontend/lib/features/vietnam_local/views/report_issue_screen.dart +++ b/frontend/lib/features/vietnam_local/views/report_issue_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../providers/safety_provider.dart'; /// Report-issue screen — `POST /api/Safety/report-issue`. diff --git a/frontend/lib/features/vietnam_local/views/safety_hub_screen.dart b/frontend/lib/features/vietnam_local/views/safety_hub_screen.dart index 4b19589..2a18652 100644 --- a/frontend/lib/features/vietnam_local/views/safety_hub_screen.dart +++ b/frontend/lib/features/vietnam_local/views/safety_hub_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator.dart'; +import '../../../core/i18n/api_message_translator_fixed.dart'; import '../models/vietnam_local_models.dart'; import '../providers/safety_provider.dart'; import '../widgets/info_card.dart'; diff --git a/frontend/test/api_message_translator_test.dart b/frontend/test/api_message_translator_test.dart index c7f46e8..46c88e6 100644 --- a/frontend/test/api_message_translator_test.dart +++ b/frontend/test/api_message_translator_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:frontend/core/i18n/api_message_translator.dart'; +import 'package:frontend/core/i18n/api_message_translator_fixed.dart'; void main() { group('ApiMessageTranslator', () { @@ -38,6 +38,31 @@ void main() { expect(ApiMessageTranslator.translate(vi), vi); }); + test('keeps foreground notification translations valid UTF-8', () { + final translated = ApiMessageTranslator.translateNotification( + 'Connection request accepted', + ); + + expect(translated, 'Yêu cầu kết nối đã được chấp nhận'); + expect(translated, isNot(contains('Ã'))); + expect(translated, isNot(contains('Ä'))); + }); + + test('translates dynamic PT connection notifications', () { + expect( + ApiMessageTranslator.translateNotification( + 'Coach Trần Minh has accepted your connection request.', + ), + 'PT Trần Minh đã chấp nhận yêu cầu kết nối của bạn.', + ); + expect( + ApiMessageTranslator.translateNotification( + 'Coach Trần Minh has rejected your connection request.', + ), + 'PT Trần Minh đã từ chối yêu cầu kết nối của bạn.', + ); + }); + test('translateList maps all items', () { final out = ApiMessageTranslator.translateList([ 'Food not found.', diff --git a/frontend/test/notification_route_navigation_test.dart b/frontend/test/notification_route_navigation_test.dart index df4f6a1..7224c24 100644 --- a/frontend/test/notification_route_navigation_test.dart +++ b/frontend/test/notification_route_navigation_test.dart @@ -1,3 +1,4 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frontend/core/services/notification_handler.dart'; @@ -156,6 +157,41 @@ void main() { expect(action.type, NotificationActionType.openGymerWeeklyReport); }); + testWidgets('foreground PT/Gymer notification closes after 5 seconds', ( + tester, + ) async { + late BuildContext screenContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + screenContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + NotificationHandler().showInAppNotification( + screenContext, + const RemoteMessage( + notification: RemoteNotification( + title: 'Connection request accepted', + body: 'Coach Trần Minh has accepted your connection request.', + ), + ), + ); + await tester.pump(); + + final snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.duration, const Duration(seconds: 5)); + expect(find.text('Yêu cầu kết nối đã được chấp nhận'), findsOneWidget); + expect( + find.text('PT Trần Minh đã chấp nhận yêu cầu kết nối của bạn.'), + findsOneWidget, + ); + }); + testWidgets('route status and numeric badge are visible', (tester) async { final notification = AppNotification.fromJson({ 'id': 'notification-1', From 38ab7e8130e03845052a195b9cc191235c2d8efb Mon Sep 17 00:00:00 2001 From: Duy163 Date: Sat, 15 Aug 2026 16:54:26 +0700 Subject: [PATCH 4/6] refactor: Update account type handling and localization - Changed account type from 'PT' to 'Coach' in the registration request and related services. - Updated role assignment logic in AuthService to reflect the new account type. - Modified UI components to display 'PT / Coach' for better clarity. - Enhanced Vietnamese translations for activity levels in the localization file. --- .../DTOs/Requests/RegisterRequest.cs | 2 +- .../MenuGreen.BusinessLogicLayer/Services/AuthService.cs | 8 ++++---- frontend/lib/core/i18n/api_message_translator.dart | 3 ++- frontend/lib/features/auth/views/register_screen.dart | 6 +++--- frontend/lib/features/coach/views/coach_main_screen.dart | 9 +++++++++ 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/RegisterRequest.cs b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/RegisterRequest.cs index 3509e63..2969af9 100644 --- a/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/RegisterRequest.cs +++ b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/RegisterRequest.cs @@ -17,7 +17,7 @@ public class RegisterRequest public string Password { get; set; } = string.Empty; [Required(ErrorMessage = "Account type is required.")] - [RegularExpression("(?i)^(User|PT)$", ErrorMessage = "Account type must be User or PT.")] + [RegularExpression("(?i)^(User|Coach)$", ErrorMessage = "Account type must be User or Coach.")] public string AccountType { get; set; } = "User"; } } diff --git a/backend/MenuGreen.BusinessLogicLayer/Services/AuthService.cs b/backend/MenuGreen.BusinessLogicLayer/Services/AuthService.cs index a3cac6f..ae67212 100644 --- a/backend/MenuGreen.BusinessLogicLayer/Services/AuthService.cs +++ b/backend/MenuGreen.BusinessLogicLayer/Services/AuthService.cs @@ -36,8 +36,8 @@ public async Task RegisterAsync(RegisterRequest request) var existingUsers = await _unitOfWork.Users.FindAsync(u => u.Email == normalizedEmail); if (existingUsers.Any()) throw new Exception("Email is already registered."); - var isPtAccount = string.Equals(request.AccountType.Trim(), "PT", StringComparison.OrdinalIgnoreCase); - var roleName = isPtAccount ? "Coach" : "User"; + var isCoachAccount = string.Equals(request.AccountType.Trim(), "Coach", StringComparison.OrdinalIgnoreCase); + var roleName = isCoachAccount ? "Coach" : "User"; var userRole = (await _unitOfWork.Roles.FindAsync( r => r.Name.ToLower() == roleName.ToLower())).FirstOrDefault(); if (userRole == null) @@ -46,7 +46,7 @@ public async Task RegisterAsync(RegisterRequest request) { Id = Guid.NewGuid(), Name = roleName, - Description = isPtAccount + Description = isCoachAccount ? "Personal trainer / Nutrition coach" : "User Role", CreatedAt = DateTime.UtcNow, @@ -89,7 +89,7 @@ public async Task RegisterAsync(RegisterRequest request) await _unitOfWork.Users.AddAsync(user); await _unitOfWork.Profiles.AddAsync(profile); await _unitOfWork.HealthProfiles.AddAsync(health); - if (isPtAccount) + if (isCoachAccount) { await _unitOfWork.CoachProfiles.AddAsync(new CoachProfile { diff --git a/frontend/lib/core/i18n/api_message_translator.dart b/frontend/lib/core/i18n/api_message_translator.dart index dfeaf4c..22d01a5 100644 --- a/frontend/lib/core/i18n/api_message_translator.dart +++ b/frontend/lib/core/i18n/api_message_translator.dart @@ -36,7 +36,8 @@ class ApiMessageTranslator { 'muscle gain': 'Tăng cơ', 'maintenance': 'Giữ dáng', 'sedentary': 'Ít vận động', - 'active': 'Vận động nhiều', + 'moderately active': 'Vận động vừa', + 'very active': 'Vận động nhiều', // Others 'casual': 'Thông thường', diff --git a/frontend/lib/features/auth/views/register_screen.dart b/frontend/lib/features/auth/views/register_screen.dart index 20363ab..6ae3ed5 100644 --- a/frontend/lib/features/auth/views/register_screen.dart +++ b/frontend/lib/features/auth/views/register_screen.dart @@ -300,15 +300,15 @@ class _RegisterScreenState extends State const SizedBox(width: 12), Expanded( child: _buildAccountTypeOption( - value: 'PT', + value: 'Coach', icon: Icons.fitness_center_rounded, - title: 'PT', + title: 'PT / Coach', subtitle: 'Quản lý học viên', ), ), ], ), - if (_accountType == 'PT') ...[ + if (_accountType == 'Coach') ...[ const SizedBox(height: 10), const Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/frontend/lib/features/coach/views/coach_main_screen.dart b/frontend/lib/features/coach/views/coach_main_screen.dart index e584547..4cb0235 100644 --- a/frontend/lib/features/coach/views/coach_main_screen.dart +++ b/frontend/lib/features/coach/views/coach_main_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/network/jwt_utils.dart'; import '../../../core/network/token_storage.dart'; +import '../../../core/utils/keyboard_aware_snackbar.dart'; import '../../advanced/views/advanced_detail_screens.dart'; import '../../auth/views/welcome_screen.dart'; import '../../main/views/main_screen.dart'; @@ -1278,6 +1279,14 @@ class _CoachNotificationsTabState extends State<_CoachNotificationsTab> // Cập nhật badge ngay khi có notification mới qua SignalR — user mở app // không cần vào tab Thông báo mới biết có notif mới. _syncBadges(); + + // Hiển thị SnackBar 5 giây cho tất cả notification + final displayTitle = notification.displayTitle?.isNotEmpty == true + ? notification.displayTitle + : notification.title; + if (displayTitle != null && displayTitle.isNotEmpty) { + context.showSuccessSnackBar(displayTitle); + } } @override From 29444fab5742c752159d9a1a9491248ea7c3f006 Mon Sep 17 00:00:00 2001 From: Duy163 Date: Sat, 15 Aug 2026 17:09:51 +0700 Subject: [PATCH 5/6] refactor: simplify notification display logic in Coach notifications - Streamlined the logic for displaying notification titles in the Coach notifications tab by removing unnecessary checks for empty strings. - Updated the profile view by removing unused imports to enhance code clarity and maintainability. --- frontend/lib/features/coach/views/coach_main_screen.dart | 6 ++---- frontend/lib/features/profile/views/profile_view.dart | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/lib/features/coach/views/coach_main_screen.dart b/frontend/lib/features/coach/views/coach_main_screen.dart index 4cb0235..8686c45 100644 --- a/frontend/lib/features/coach/views/coach_main_screen.dart +++ b/frontend/lib/features/coach/views/coach_main_screen.dart @@ -1281,10 +1281,8 @@ class _CoachNotificationsTabState extends State<_CoachNotificationsTab> _syncBadges(); // Hiển thị SnackBar 5 giây cho tất cả notification - final displayTitle = notification.displayTitle?.isNotEmpty == true - ? notification.displayTitle - : notification.title; - if (displayTitle != null && displayTitle.isNotEmpty) { + final displayTitle = notification.displayTitle; + if (displayTitle.isNotEmpty) { context.showSuccessSnackBar(displayTitle); } } diff --git a/frontend/lib/features/profile/views/profile_view.dart b/frontend/lib/features/profile/views/profile_view.dart index 5460300..5cede29 100644 --- a/frontend/lib/features/profile/views/profile_view.dart +++ b/frontend/lib/features/profile/views/profile_view.dart @@ -4,9 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/app_colors.dart'; -import '../../../core/i18n/api_message_translator_fixed.dart'; import '../../../core/i18n/api_message_translator.dart'; -import '../../../core/network/token_storage.dart'; import '../../casual/views/casual_hub_screen.dart'; import '../repositories/profile_repository.dart'; import '../../auth/views/welcome_screen.dart'; From 02aef8333ffec1176a1fce321ae2d069481bb2c6 Mon Sep 17 00:00:00 2001 From: Minmin_Dev Date: Sat, 15 Aug 2026 22:15:11 +0700 Subject: [PATCH 6/6] Refactor analytics and user management components - Removed unused imports and variables in analytics API and components. - Improved DonutChart component by calculating dash offsets more efficiently. - Updated useEffect hooks in various components to use setTimeout for better performance. - Introduced ManageMembershipDialog for managing user memberships. - Enhanced user management UI to display membership status and allow management actions. - Added new API endpoints for user membership management. - Updated user types to include membership details. --- .../Controllers/AdminMembershipController.cs | 93 ++++++ .../DTOs/Requests/AdminMembershipRequests.cs | 36 ++ .../Responses/AdminMembershipResponses.cs | 29 ++ .../DTOs/Responses/UserAdminResponse.cs | 5 + .../DependencyInjection.cs | 1 + .../Interfaces/IAdminMembershipService.cs | 15 + .../Services/AdminMembershipService.cs | 308 ++++++++++++++++++ .../Services/UserService.cs | 63 ++-- .../admin_membership_management_report.md | 80 +++++ .../features/analytics/api/analytics-api.ts | 1 - .../analytics/components/analytics-cards.tsx | 9 +- .../analytics/components/analytics-ui.tsx | 1 - .../nutrition-analytics-dashboard.tsx | 5 +- .../revenue-statistics-dashboard.tsx | 10 +- .../features/analytics/hooks/use-analytics.ts | 41 ++- .../features/auth/components/login-form.tsx | 17 +- .../dashboard/hooks/use-dashboard-metrics.ts | 3 +- .../foods/components/food-form-dialog.tsx | 33 +- .../features/foods/hooks/use-foods.ts | 3 +- .../components/ingredient-form-dialog.tsx | 9 +- .../ingredients/hooks/use-ingredients.ts | 6 +- .../jobs/components/admin-job-panel.tsx | 2 +- .../distribute-meal-plan-dialog.tsx | 6 +- .../components/meal-plan-form-dialog.tsx | 23 +- .../meal-plans/hooks/use-meal-plans.ts | 3 +- .../components/admin-migration-panel.tsx | 8 +- .../components/admin-notification-panel.tsx | 3 +- .../recipes/components/recipe-form-dialog.tsx | 14 +- .../features/recipes/hooks/use-recipes.ts | 3 +- .../subscription-plan-form-dialog.tsx | 7 +- .../hooks/use-subscription-plans.ts | 3 +- .../features/users/api/admin-user-api.ts | 28 +- .../components/manage-membership-dialog.tsx | 175 ++++++++++ .../users/components/user-management.tsx | 63 +++- .../features/users/hooks/use-users.ts | 3 +- frontend-web/features/users/types.ts | 33 ++ frontend-web/lib/api/endpoints.ts | 5 + frontend-web/providers/auth-provider.tsx | 15 +- 38 files changed, 1042 insertions(+), 120 deletions(-) create mode 100644 backend/MenuGreen.API/Controllers/AdminMembershipController.cs create mode 100644 backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AdminMembershipRequests.cs create mode 100644 backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/AdminMembershipResponses.cs create mode 100644 backend/MenuGreen.BusinessLogicLayer/Interfaces/IAdminMembershipService.cs create mode 100644 backend/MenuGreen.BusinessLogicLayer/Services/AdminMembershipService.cs create mode 100644 docs/05-reports/admin_membership_management_report.md create mode 100644 frontend-web/features/users/components/manage-membership-dialog.tsx diff --git a/backend/MenuGreen.API/Controllers/AdminMembershipController.cs b/backend/MenuGreen.API/Controllers/AdminMembershipController.cs new file mode 100644 index 0000000..7960ced --- /dev/null +++ b/backend/MenuGreen.API/Controllers/AdminMembershipController.cs @@ -0,0 +1,93 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using MenuGreen.BusinessLogicLayer.DTOs.Requests; +using MenuGreen.BusinessLogicLayer.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MenuGreen.API.Controllers +{ + [ApiController] + [Route("api/admin/users/{userId:guid}/memberships")] + [Authorize(Roles = "Admin")] + public class AdminMembershipController : ControllerBase + { + private readonly IAdminMembershipService _service; + + public AdminMembershipController(IAdminMembershipService service) + { + _service = service; + } + + [HttpGet] + public async Task Get(Guid userId) + { + try + { + return Ok(await _service.GetAsync(userId)); + } + catch (Exception ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + [HttpPost] + public async Task Grant(Guid userId, [FromBody] AdminGrantMembershipRequest request) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + if (!TryGetAdminId(out var adminUserId)) return Unauthorized(); + try + { + return Ok(await _service.GrantAsync(adminUserId, userId, request)); + } + catch (Exception ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + [HttpPost("{subscriptionId:guid}/extend")] + public async Task Extend( + Guid userId, + Guid subscriptionId, + [FromBody] AdminExtendMembershipRequest request) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + if (!TryGetAdminId(out var adminUserId)) return Unauthorized(); + try + { + return Ok(await _service.ExtendAsync(adminUserId, userId, subscriptionId, request)); + } + catch (Exception ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + [HttpPost("{subscriptionId:guid}/revoke")] + public async Task Revoke( + Guid userId, + Guid subscriptionId, + [FromBody] AdminRevokeMembershipRequest request) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + if (!TryGetAdminId(out var adminUserId)) return Unauthorized(); + try + { + return Ok(await _service.RevokeAsync(adminUserId, userId, subscriptionId, request)); + } + catch (Exception ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + private bool TryGetAdminId(out Guid adminUserId) + { + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(raw, out adminUserId); + } + } +} diff --git a/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AdminMembershipRequests.cs b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AdminMembershipRequests.cs new file mode 100644 index 0000000..a1acca1 --- /dev/null +++ b/backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AdminMembershipRequests.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MenuGreen.BusinessLogicLayer.DTOs.Requests +{ + public class AdminGrantMembershipRequest + { + [Required] + public Guid SubscriptionPlanId { get; set; } + + [Range(1, 3650)] + public int DurationDays { get; set; } = 30; + + public DateTime? StartDate { get; set; } + + [MaxLength(500)] + public string? Note { get; set; } + } + + public class AdminExtendMembershipRequest + { + [Range(1, 3650)] + public int DurationDays { get; set; } = 30; + + [MaxLength(500)] + public string? Note { get; set; } + } + + public class AdminRevokeMembershipRequest + { + [Required] + [MinLength(3)] + [MaxLength(500)] + public string Reason { get; set; } = string.Empty; + } +} diff --git a/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/AdminMembershipResponses.cs b/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/AdminMembershipResponses.cs new file mode 100644 index 0000000..27dd362 --- /dev/null +++ b/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/AdminMembershipResponses.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace MenuGreen.BusinessLogicLayer.DTOs.Responses +{ + public class AdminUserMembershipResponse + { + public Guid UserId { get; set; } + public string Tier { get; set; } = "free"; + public IReadOnlyList Entitlements { get; set; } = Array.Empty(); + public IReadOnlyList FeatureGroups { get; set; } = Array.Empty(); + public DateTime? ExpiresAt { get; set; } + public IReadOnlyList Memberships { get; set; } = Array.Empty(); + } + + public class AdminMembershipItemResponse + { + public Guid SubscriptionId { get; set; } + public Guid PlanId { get; set; } + public string PlanName { get; set; } = string.Empty; + public string FeatureGroup { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public DateTime? CancelledAt { get; set; } + public DateTime? RenewedAt { get; set; } + public int DaysRemaining { get; set; } + } +} diff --git a/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/UserAdminResponse.cs b/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/UserAdminResponse.cs index f0d5979..47f7eab 100644 --- a/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/UserAdminResponse.cs +++ b/backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/UserAdminResponse.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace MenuGreen.BusinessLogicLayer.DTOs.Responses { @@ -8,6 +9,10 @@ public class UserAdminResponse public string Email { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public string Role { get; set; } = string.Empty; + public string MembershipTier { get; set; } = "none"; + public string MembershipStatus { get; set; } = "NoSubscription"; + public IReadOnlyList Entitlements { get; set; } = new[] { "free_features" }; + public DateTime? MembershipExpiresAt { get; set; } public bool IsActive { get; set; } public bool EmailConfirmed { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/backend/MenuGreen.BusinessLogicLayer/DependencyInjection.cs b/backend/MenuGreen.BusinessLogicLayer/DependencyInjection.cs index 7cc9636..e5a2424 100644 --- a/backend/MenuGreen.BusinessLogicLayer/DependencyInjection.cs +++ b/backend/MenuGreen.BusinessLogicLayer/DependencyInjection.cs @@ -34,6 +34,7 @@ public static IServiceCollection AddBusinessLogicLayer(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/MenuGreen.BusinessLogicLayer/Interfaces/IAdminMembershipService.cs b/backend/MenuGreen.BusinessLogicLayer/Interfaces/IAdminMembershipService.cs new file mode 100644 index 0000000..0dd6cb2 --- /dev/null +++ b/backend/MenuGreen.BusinessLogicLayer/Interfaces/IAdminMembershipService.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using MenuGreen.BusinessLogicLayer.DTOs.Requests; +using MenuGreen.BusinessLogicLayer.DTOs.Responses; + +namespace MenuGreen.BusinessLogicLayer.Interfaces +{ + public interface IAdminMembershipService + { + Task GetAsync(Guid userId); + Task GrantAsync(Guid adminUserId, Guid userId, AdminGrantMembershipRequest request); + Task ExtendAsync(Guid adminUserId, Guid userId, Guid subscriptionId, AdminExtendMembershipRequest request); + Task RevokeAsync(Guid adminUserId, Guid userId, Guid subscriptionId, AdminRevokeMembershipRequest request); + } +} diff --git a/backend/MenuGreen.BusinessLogicLayer/Services/AdminMembershipService.cs b/backend/MenuGreen.BusinessLogicLayer/Services/AdminMembershipService.cs new file mode 100644 index 0000000..5823cb0 --- /dev/null +++ b/backend/MenuGreen.BusinessLogicLayer/Services/AdminMembershipService.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using MenuGreen.BusinessLogicLayer.DTOs.Requests; +using MenuGreen.BusinessLogicLayer.DTOs.Responses; +using MenuGreen.BusinessLogicLayer.Helpers; +using MenuGreen.BusinessLogicLayer.Interfaces; +using MenuGreen.DataAccessLayer.Entities; +using MenuGreen.DataAccessLayer.Interfaces; + +namespace MenuGreen.BusinessLogicLayer.Services +{ + public class AdminMembershipService : IAdminMembershipService + { + private readonly IUnitOfWork _unitOfWork; + private readonly INotificationService _notificationService; + private readonly ICacheService _cache; + + public AdminMembershipService( + IUnitOfWork unitOfWork, + INotificationService notificationService, + ICacheService cache) + { + _unitOfWork = unitOfWork; + _notificationService = notificationService; + _cache = cache; + } + + public async Task GetAsync(Guid userId) + { + await EnsureUserAsync(userId); + return await BuildResponseAsync(userId); + } + + public async Task GrantAsync( + Guid adminUserId, + Guid userId, + AdminGrantMembershipRequest request) + { + var user = await EnsureUserAsync(userId); + if (!user.IsActive) throw new InvalidOperationException("Cannot grant a plan to a locked account."); + + var plan = await _unitOfWork.SubscriptionPlans.GetByIdAsync(request.SubscriptionPlanId) + ?? throw new InvalidOperationException("Subscription plan not found."); + if (plan.IsActive == false) throw new InvalidOperationException("Subscription plan is inactive."); + if (IsBaselineFreePlan(plan)) + throw new InvalidOperationException("The Free plan is available by default and must not be granted as a subscription."); + + var now = DateTime.UtcNow; + var existing = await _unitOfWork.UserSubscriptions.FindAsync(x => + x.UserId == userId && x.SubscriptionPlanId == plan.Id); + if (existing.Any(x => + (string.Equals(x.Status, "Active", StringComparison.OrdinalIgnoreCase) && x.EndDate > now) || + string.Equals(x.Status, "PendingPayment", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException("The account already has this plan active or pending payment."); + } + + var startDate = NormalizeUtc(request.StartDate ?? now); + if (startDate < now.AddDays(-1)) + throw new InvalidOperationException("Start date cannot be more than one day in the past."); + + var subscription = new UserSubscription + { + Id = Guid.NewGuid(), + UserId = userId, + SubscriptionPlanId = plan.Id, + Status = "Active", + StartDate = startDate, + EndDate = startDate.AddDays(request.DurationDays), + CreatedAt = now, + UpdatedAt = now + }; + + await _unitOfWork.UserSubscriptions.AddAsync(subscription); + await _unitOfWork.SubscriptionTransactions.AddAsync(new SubscriptionTransaction + { + Id = Guid.NewGuid(), + UserId = userId, + UserSubscriptionId = subscription.Id, + UserSubscription = subscription, + TransactionType = "AdminGrant", + Amount = 0, + Status = "Success", + Note = BuildTransactionNote(adminUserId, request.Note), + TransactionDate = now, + CreatedAt = now + }); + await AddAuditAsync(adminUserId, "AdminGrantMembership", subscription.Id, new + { + TargetUserId = userId, + PlanId = plan.Id, + PlanName = plan.Name, + request.DurationDays, + StartDate = startDate, + request.Note + }); + await _unitOfWork.CompleteAsync(); + await AfterMembershipChangedAsync(userId, "Gói dịch vụ đã được cấp", $"Quản trị viên đã cấp gói {plan.Name} cho tài khoản của bạn."); + return await BuildResponseAsync(userId); + } + + public async Task ExtendAsync( + Guid adminUserId, + Guid userId, + Guid subscriptionId, + AdminExtendMembershipRequest request) + { + await EnsureUserAsync(userId); + var subscription = await GetOwnedSubscriptionAsync(userId, subscriptionId); + if (string.Equals(subscription.Status, "Cancelled", StringComparison.OrdinalIgnoreCase) || + string.Equals(subscription.Status, "PendingPayment", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Cancelled or pending subscriptions cannot be extended."); + } + + var now = DateTime.UtcNow; + var baseDate = subscription.EndDate > now ? subscription.EndDate : now; + subscription.EndDate = baseDate.AddDays(request.DurationDays); + subscription.Status = "Active"; + subscription.RenewedAt = now; + subscription.UpdatedAt = now; + _unitOfWork.UserSubscriptions.Update(subscription); + + await _unitOfWork.SubscriptionTransactions.AddAsync(new SubscriptionTransaction + { + Id = Guid.NewGuid(), + UserId = userId, + UserSubscriptionId = subscription.Id, + TransactionType = "AdminExtend", + Amount = 0, + Status = "Success", + Note = BuildTransactionNote(adminUserId, request.Note), + TransactionDate = now, + CreatedAt = now + }); + await AddAuditAsync(adminUserId, "AdminExtendMembership", subscription.Id, new + { + TargetUserId = userId, + request.DurationDays, + NewEndDate = subscription.EndDate, + request.Note + }); + await _unitOfWork.CompleteAsync(); + await AfterMembershipChangedAsync(userId, "Gói dịch vụ đã được gia hạn", $"Gói của bạn được gia hạn thêm {request.DurationDays} ngày."); + return await BuildResponseAsync(userId); + } + + public async Task RevokeAsync( + Guid adminUserId, + Guid userId, + Guid subscriptionId, + AdminRevokeMembershipRequest request) + { + await EnsureUserAsync(userId); + var subscription = await GetOwnedSubscriptionAsync(userId, subscriptionId); + if (string.Equals(subscription.Status, "Cancelled", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Subscription is already cancelled."); + + var now = DateTime.UtcNow; + subscription.Status = "Cancelled"; + subscription.CancelledAt = now; + subscription.UpdatedAt = now; + _unitOfWork.UserSubscriptions.Update(subscription); + + var pendingPayments = await _unitOfWork.Payments.FindAsync(x => + x.UserSubscriptionId == subscription.Id && x.Status == "PENDING" && x.Provider == "SEPAY"); + foreach (var payment in pendingPayments) + { + payment.Status = "CANCELLED"; + payment.UpdatedAt = DateTimeOffset.UtcNow; + _unitOfWork.Payments.Update(payment); + } + + await _unitOfWork.SubscriptionTransactions.AddAsync(new SubscriptionTransaction + { + Id = Guid.NewGuid(), + UserId = userId, + UserSubscriptionId = subscription.Id, + TransactionType = "AdminRevoke", + Amount = 0, + Status = "Success", + Note = BuildTransactionNote(adminUserId, request.Reason), + TransactionDate = now, + CreatedAt = now + }); + await AddAuditAsync(adminUserId, "AdminRevokeMembership", subscription.Id, new + { + TargetUserId = userId, + request.Reason + }); + await _unitOfWork.CompleteAsync(); + await AfterMembershipChangedAsync(userId, "Gói dịch vụ đã bị thu hồi", "Quản trị viên đã thu hồi một gói dịch vụ khỏi tài khoản của bạn."); + return await BuildResponseAsync(userId); + } + + private async Task EnsureUserAsync(Guid userId) => + await _unitOfWork.Users.GetByIdAsync(userId) + ?? throw new InvalidOperationException("Account not found."); + + private async Task GetOwnedSubscriptionAsync(Guid userId, Guid subscriptionId) + { + var subscription = await _unitOfWork.UserSubscriptions.GetByIdAsync(subscriptionId) + ?? throw new InvalidOperationException("Subscription not found."); + if (subscription.UserId != userId) throw new UnauthorizedAccessException("Subscription does not belong to this account."); + return subscription; + } + + private async Task BuildResponseAsync(Guid userId) + { + var subscriptions = (await _unitOfWork.UserSubscriptions.FindAsync(x => x.UserId == userId)) + .OrderByDescending(x => x.CreatedAt) + .ToList(); + var plans = (await _unitOfWork.SubscriptionPlans.GetAllAsync()).ToDictionary(x => x.Id); + var snapshots = subscriptions.Select(subscription => + { + plans.TryGetValue(subscription.SubscriptionPlanId, out var plan); + return new SubscriptionAccessSnapshot( + subscription.Status, + subscription.StartDate, + subscription.EndDate, + plan?.FeatureGroup, + plan?.Name); + }); + var access = FeatureAccessResolver.Resolve(snapshots, DateTime.UtcNow); + var items = subscriptions.Select(subscription => + { + plans.TryGetValue(subscription.SubscriptionPlanId, out var plan); + return new AdminMembershipItemResponse + { + SubscriptionId = subscription.Id, + PlanId = subscription.SubscriptionPlanId, + PlanName = plan?.Name ?? string.Empty, + FeatureGroup = plan?.FeatureGroup ?? string.Empty, + Status = subscription.Status, + StartDate = subscription.StartDate, + EndDate = subscription.EndDate, + CancelledAt = subscription.CancelledAt, + RenewedAt = subscription.RenewedAt, + DaysRemaining = Math.Max(0, (int)Math.Ceiling((subscription.EndDate - DateTime.UtcNow).TotalDays)) + }; + }).ToList(); + + return new AdminUserMembershipResponse + { + UserId = userId, + Tier = access.Tier, + Entitlements = access.Entitlements, + FeatureGroups = access.FeatureGroups, + ExpiresAt = access.ExpiresAt, + Memberships = items + }; + } + + private async Task AddAuditAsync(Guid adminUserId, string action, Guid subscriptionId, object metadata) + { + await _unitOfWork.ActivityLogs.AddAsync(new ActivityLog + { + Id = Guid.NewGuid(), + UserId = adminUserId, + Action = action, + EntityType = "UserSubscription", + EntityId = subscriptionId, + Metadata = JsonSerializer.Serialize(metadata), + CreatedAt = DateTimeOffset.UtcNow + }); + } + + private async Task AfterMembershipChangedAsync(Guid userId, string title, string body) + { + await _cache.RemoveAsync(CacheKeys.UserSubscription(userId)); + try + { + await _notificationService.SendAsync(new NotificationSendRequest + { + UserId = userId, + Type = "admin_membership_changed", + Title = title, + Body = body, + ScheduledAt = DateTimeOffset.UtcNow + }); + } + catch + { + // Membership changes must not fail because notification delivery failed. + } + } + + private static bool IsBaselineFreePlan(SubscriptionPlan plan) + { + var group = plan.FeatureGroup?.Trim(); + var name = plan.Name?.Trim(); + return string.Equals(group, "basic", StringComparison.OrdinalIgnoreCase) + || string.Equals(group, "free", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Cơ bản", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Basic", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Free", StringComparison.OrdinalIgnoreCase); + } + + private static DateTime NormalizeUtc(DateTime value) => + value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + + private static string BuildTransactionNote(Guid adminUserId, string? note) => + $"Admin {adminUserId}: {(string.IsNullOrWhiteSpace(note) ? "No note" : note.Trim())}"; + } +} diff --git a/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs b/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs index 953e222..a12bb1e 100644 --- a/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs +++ b/backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs @@ -46,17 +46,42 @@ public async Task> GetAllUsersAsync() var users = await _unitOfWork.Users.GetAllAsync(); var profiles = await _unitOfWork.Profiles.GetAllAsync(); var roles = await _unitOfWork.Roles.GetAllAsync(); + var subscriptions = (await _unitOfWork.UserSubscriptions.GetAllAsync()).ToList(); + var plans = (await _unitOfWork.SubscriptionPlans.GetAllAsync()).ToDictionary(x => x.Id); + var now = DateTime.UtcNow; var result = users.Select(u => { var p = profiles.FirstOrDefault(prof => prof.UserId == u.Id); var r = roles.FirstOrDefault(role => role.Id == u.RoleId); + var userSubscriptions = subscriptions.Where(x => x.UserId == u.Id).ToList(); + var snapshots = userSubscriptions.Select(subscription => + { + plans.TryGetValue(subscription.SubscriptionPlanId, out var plan); + return new SubscriptionAccessSnapshot( + subscription.Status, + subscription.StartDate, + subscription.EndDate, + plan?.FeatureGroup, + plan?.Name); + }); + var access = FeatureAccessResolver.Resolve(snapshots, now); + var hasActive = userSubscriptions.Any(x => + string.Equals(x.Status, "Active", StringComparison.OrdinalIgnoreCase) + && x.StartDate <= now + && x.EndDate > now); + var latest = userSubscriptions.OrderByDescending(x => x.UpdatedAt).FirstOrDefault(); + var membershipStatus = GetMembershipStatus(latest, hasActive, now); return new UserAdminResponse { Id = u.Id, Email = u.Email, FullName = p?.FullName ?? "", Role = r?.Name ?? "Free", + MembershipTier = hasActive ? access.Tier : "none", + MembershipStatus = membershipStatus, + Entitlements = access.Entitlements, + MembershipExpiresAt = access.ExpiresAt, IsActive = u.IsActive, EmailConfirmed = u.EmailConfirmed, CreatedAt = u.CreatedAt, @@ -67,30 +92,30 @@ public async Task> GetAllUsersAsync() return result; } - public async Task GetUserByIdAsync(Guid userId) + private static string GetMembershipStatus( + MenuGreen.DataAccessLayer.Entities.UserSubscription? latest, + bool hasActive, + DateTime now) { - var user = await _unitOfWork.Users.GetByIdAsync(userId); - if (user == null) throw new Exception("Account not found."); + if (hasActive) return "Active"; + if (latest == null) return "NoSubscription"; - var profile = await _unitOfWork.Profiles.GetByIdAsync(userId); - var roleName = "Free"; - var roleEntities = await _unitOfWork.Roles.FindAsync(r => r.Id == user.RoleId); - if (roleEntities.Any()) + if (string.Equals(latest.Status, "Active", StringComparison.OrdinalIgnoreCase)) { - roleName = roleEntities.First().Name; + if (latest.StartDate > now) return "Scheduled"; + if (latest.EndDate <= now) return "Expired"; } - return new UserAdminResponse - { - Id = user.Id, - Email = user.Email, - FullName = profile?.FullName ?? "", - Role = roleName, - IsActive = user.IsActive, - EmailConfirmed = user.EmailConfirmed, - CreatedAt = user.CreatedAt, - LastSignInAt = user.LastSignInAt - }; + return string.IsNullOrWhiteSpace(latest.Status) + ? "NoSubscription" + : latest.Status; + } + + public async Task GetUserByIdAsync(Guid userId) + { + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) throw new Exception("Account not found."); + return (await GetAllUsersAsync()).First(x => x.Id == userId); } public async Task ToggleUserStatusAsync(Guid userId) diff --git a/docs/05-reports/admin_membership_management_report.md b/docs/05-reports/admin_membership_management_report.md new file mode 100644 index 0000000..e26bea2 --- /dev/null +++ b/docs/05-reports/admin_membership_management_report.md @@ -0,0 +1,80 @@ +# Báo cáo triển khai quản lý gói thành viên bởi Admin + +Ngày thực hiện: 15/08/2026 + +## Kết quả + +Đã bổ sung luồng để Admin xem, cấp, gia hạn và thu hồi gói tính năng cho từng tài khoản. Role và gói thành viên được hiển thị, quản lý độc lập: + +- `Role` tiếp tục lấy từ role hiện tại của tài khoản. +- `Gói thành viên` được tính từ các subscription còn hiệu lực. +- Cấp gói không tự đổi role của tài khoản. +- Tài khoản không có subscription hợp lệ tiếp tục dùng tier `free` và các `free_features` mặc định. + +Không tạo migration, không đổi schema và không chỉnh sửa file trong `backend/database`. + +## Backend + +Đã tạo nhóm API chỉ cho role `Admin`: + +| Method | Endpoint | Chức năng | +|---|---|---| +| `GET` | `/api/admin/users/{userId}/memberships` | Xem tier, entitlement và lịch sử subscription | +| `POST` | `/api/admin/users/{userId}/memberships` | Cấp một gói đang hoạt động | +| `POST` | `/api/admin/users/{userId}/memberships/{subscriptionId}/extend` | Gia hạn subscription | +| `POST` | `/api/admin/users/{userId}/memberships/{subscriptionId}/revoke` | Thu hồi subscription | + +Các quy tắc đã áp dụng: + +- Chỉ Admin đã xác thực mới gọi được API. +- Không cấp gói cho tài khoản đang khóa. +- Không cấp trực tiếp gói Free/Basic vì đây là quyền mặc định. +- Không tạo trùng cùng một gói khi subscription đang active hoặc chờ thanh toán. +- Thời hạn cấp/gia hạn nằm trong khoảng 1–3650 ngày. +- Subscription bị thu hồi chuyển sang `Cancelled`; payment SePay đang chờ liên quan được chuyển sang `CANCELLED`. +- Sau thay đổi, cache quyền subscription bị xóa và người dùng nhận notification. + +## Audit + +Mỗi thao tác quản trị tạo đồng thời: + +- Một `SubscriptionTransaction` có loại `AdminGrant`, `AdminExtend` hoặc `AdminRevoke`, số tiền bằng 0. +- Một `ActivityLog` ghi Admin thực hiện, tài khoản đích, subscription, plan, thời hạn và ghi chú/lý do liên quan. + +Nhờ đó việc cấp quyền thủ công không bị nhầm với giao dịch thanh toán của người dùng và có thể truy vết. + +## Giao diện Admin + +Trang quản lý người dùng đã được tách rõ: + +- Cột `Role`: hiển thị role tài khoản. +- Cột `Gói thành viên`: hiển thị tier hiện tại và thời điểm hết hạn. +- Nút `Quản lý gói`: mở hộp thoại xem lịch sử, cấp gói mới, gia hạn hoặc thu hồi. +- Danh sách chọn chỉ hiển thị subscription plan đang hoạt động và loại bỏ Free/Basic. + +API danh sách người dùng cũng trả thêm `membershipTier`, `membershipStatus`, `entitlements` và `membershipExpiresAt` để giao diện không dùng lẫn tier như role. + +## Các file chính + +- `backend/MenuGreen.API/Controllers/AdminMembershipController.cs` +- `backend/MenuGreen.BusinessLogicLayer/Services/AdminMembershipService.cs` +- `backend/MenuGreen.BusinessLogicLayer/Interfaces/IAdminMembershipService.cs` +- `backend/MenuGreen.BusinessLogicLayer/DTOs/Requests/AdminMembershipRequests.cs` +- `backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/AdminMembershipResponses.cs` +- `backend/MenuGreen.BusinessLogicLayer/DTOs/Responses/UserAdminResponse.cs` +- `backend/MenuGreen.BusinessLogicLayer/Services/UserService.cs` +- `frontend-web/features/users/components/manage-membership-dialog.tsx` +- `frontend-web/features/users/components/user-management.tsx` +- `frontend-web/features/users/api/admin-user-api.ts` + +## Kiểm tra + +- `dotnet build MenuGreen.sln --no-restore --verbosity minimal`: thành công, 0 error. Có 5 warning đã tồn tại tại các module khác. +- `pnpm exec tsc --noEmit`: thành công. +- ESLint trên toàn bộ file frontend đã thay đổi: thành công. +- `pnpm build`: production build thành công, trang `/dashboard/users` được sinh thành công. +- `git diff -- backend/database`: không có thay đổi. + +## Lưu ý vận hành + +Luồng mua Office hiện hữu của hệ thống vẫn có thể thay đổi role sang Office; phần triển khai này không sửa hành vi thanh toán cũ. Riêng thao tác cấp gói bằng Admin chỉ tạo entitlement qua subscription và không thay đổi role. Đây là ranh giới cần giữ khi tiếp tục chuẩn hóa toàn bộ role trong một đợt refactor riêng. diff --git a/frontend-web/features/analytics/api/analytics-api.ts b/frontend-web/features/analytics/api/analytics-api.ts index 1430a24..b660663 100644 --- a/frontend-web/features/analytics/api/analytics-api.ts +++ b/frontend-web/features/analytics/api/analytics-api.ts @@ -20,7 +20,6 @@ import type { UserInsightsResponse, RevenueTimeSeriesPoint, RevenueByPlan, - RevenueStats, } from "../types/analytics-types"; // ============================================ diff --git a/frontend-web/features/analytics/components/analytics-cards.tsx b/frontend-web/features/analytics/components/analytics-cards.tsx index 4919bd2..6c69798 100644 --- a/frontend-web/features/analytics/components/analytics-cards.tsx +++ b/frontend-web/features/analytics/components/analytics-cards.tsx @@ -153,8 +153,6 @@ export function DonutChart({ const circumference = 2 * Math.PI * radius; const center = size / 2; - let cumulativePercent = 0; - return (
@@ -172,8 +170,11 @@ export function DonutChart({ {segments.map((segment, index) => { const percent = total > 0 ? segment.value / total : 0; const dashLength = circumference * percent; - const dashOffset = circumference * cumulativePercent; - cumulativePercent += percent; + const precedingValue = segments + .slice(0, index) + .reduce((sum, item) => sum + item.value, 0); + const dashOffset = + circumference * (total > 0 ? precedingValue / total : 0); return ( ("30days"); - const [dateRange, setDateRange] = useState(() => { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - return { - from: new Date(today.getTime() - 30 * 86400000), - to: new Date(today.getTime() + 86400000 - 1), - }; - }); const { data: timeSeriesData, diff --git a/frontend-web/features/analytics/hooks/use-analytics.ts b/frontend-web/features/analytics/hooks/use-analytics.ts index bb7270a..771c928 100644 --- a/frontend-web/features/analytics/hooks/use-analytics.ts +++ b/frontend-web/features/analytics/hooks/use-analytics.ts @@ -6,7 +6,6 @@ import { getAnalyticsDashboard, getMetrics, getTopEvents, - getFunnel, getMealOnboardingFunnel, getSubscriptionFunnel, getCohort, @@ -120,7 +119,8 @@ export function useNutritionAnalytics( }, [datePreset, customRange, enabled]); useEffect(() => { - fetchData(); + const timeoutId = window.setTimeout(fetchData, 0); + return () => window.clearTimeout(timeoutId); }, [fetchData]); const changePreset = useCallback((newPreset: DatePreset) => { @@ -168,7 +168,8 @@ export function useAnalyticsDashboard() { }, []); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { dashboard, isLoading, error, refetch: fetch }; @@ -194,7 +195,8 @@ export function useAnalyticsMetrics(datePreset: DatePreset = "30days") { }, [datePreset]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { metrics, isLoading, error, refetch: fetch }; @@ -223,7 +225,8 @@ export function useTopEvents(datePreset: DatePreset = "30days") { }, [datePreset]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { events, isLoading, error, refetch: fetch }; @@ -251,7 +254,8 @@ export function useFunnel(type: "onboarding" | "subscription" = "onboarding") { }, [type]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { funnel, isLoading, error, refetch: fetch }; @@ -276,7 +280,8 @@ export function useCohort() { }, []); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { cohorts, isLoading, error, refetch: fetch }; @@ -303,7 +308,8 @@ export function useChurnRisk() { }, []); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { users, isLoading, error, refetch: fetch }; @@ -330,7 +336,8 @@ export function useInactiveUsers() { }, []); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { users, isLoading, error, refetch: fetch }; @@ -341,6 +348,9 @@ export function useActivityLog(params?: { action?: string; pageSize?: number; }) { + const userId = params?.userId; + const action = params?.action; + const pageSize = params?.pageSize; const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -349,7 +359,7 @@ export function useActivityLog(params?: { setIsLoading(true); setError(null); try { - const data = await getActivityLog(params); + const data = await getActivityLog({ userId, action, pageSize }); setLogs(data); } catch (err) { setError( @@ -358,10 +368,11 @@ export function useActivityLog(params?: { } finally { setIsLoading(false); } - }, [params?.userId, params?.action, params?.pageSize]); + }, [userId, action, pageSize]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { logs, isLoading, error, refetch: fetch }; @@ -394,7 +405,8 @@ export function useRevenueTimeSeries(datePreset: DatePreset = "30days") { }, [datePreset]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { data, isLoading, error, refetch: fetch }; @@ -423,7 +435,8 @@ export function useRevenueByPlan(datePreset: DatePreset = "30days") { }, [datePreset]); useEffect(() => { - fetch(); + const timeoutId = window.setTimeout(fetch, 0); + return () => window.clearTimeout(timeoutId); }, [fetch]); return { data, isLoading, error, refetch: fetch }; diff --git a/frontend-web/features/auth/components/login-form.tsx b/frontend-web/features/auth/components/login-form.tsx index 40dfab3..179ffa1 100644 --- a/frontend-web/features/auth/components/login-form.tsx +++ b/frontend-web/features/auth/components/login-form.tsx @@ -20,15 +20,14 @@ export function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(null); + const queryError = + searchParams.get("error") === "not-admin" + ? "Tài khoản không có quyền Admin." + : null; + const [submitError, setSubmitError] = useState(null); + const error = submitError ?? queryError; const [loading, setLoading] = useState(false); - useEffect(() => { - if (searchParams.get("error") === "not-admin") { - setError("Tài khoản không có quyền Admin."); - } - }, [searchParams]); - useEffect(() => { if (isReady && isAuthenticated && isAdmin) { router.replace("/dashboard"); @@ -37,14 +36,14 @@ export function LoginForm() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - setError(null); + setSubmitError(null); setLoading(true); const result = await login({ email, password }); setLoading(false); if (!result.success) { - setError(result.message); + setSubmitError(result.message); return; } diff --git a/frontend-web/features/dashboard/hooks/use-dashboard-metrics.ts b/frontend-web/features/dashboard/hooks/use-dashboard-metrics.ts index 0a4a8ff..d07e318 100644 --- a/frontend-web/features/dashboard/hooks/use-dashboard-metrics.ts +++ b/frontend-web/features/dashboard/hooks/use-dashboard-metrics.ts @@ -37,7 +37,8 @@ export function useDashboardMetrics(topCount = 10) { }, [topCount]); useEffect(() => { - reload(); + const timeoutId = window.setTimeout(reload, 0); + return () => window.clearTimeout(timeoutId); }, [reload]); return { metrics, revenue, userMetrics, loading, error, reload }; diff --git a/frontend-web/features/foods/components/food-form-dialog.tsx b/frontend-web/features/foods/components/food-form-dialog.tsx index 0b6a59f..e1254da 100644 --- a/frontend-web/features/foods/components/food-form-dialog.tsx +++ b/frontend-web/features/foods/components/food-form-dialog.tsx @@ -34,22 +34,25 @@ export function FoodFormDialog({ useEffect(() => { if (!open) return; - setForm(food ? foodToFormState(food) : emptyFoodForm()); - setError(null); + const timeoutId = window.setTimeout(() => { + setForm(food ? foodToFormState(food) : emptyFoodForm()); + setError(null); - if (food?.id) { - foodApi - .getAllergenTags(food.id) - .then((tags) => { - setForm((current) => ({ - ...current, - allergenKeys: tags.allergenKeys ?? [], - })); - }) - .catch(() => { - // Admin có thể chưa có quyền — bỏ qua - }); - } + if (food?.id) { + foodApi + .getAllergenTags(food.id) + .then((tags) => { + setForm((current) => ({ + ...current, + allergenKeys: tags.allergenKeys ?? [], + })); + }) + .catch(() => { + // Admin có thể chưa có quyền — bỏ qua + }); + } + }, 0); + return () => window.clearTimeout(timeoutId); }, [food, open]); if (!open) return null; diff --git a/frontend-web/features/foods/hooks/use-foods.ts b/frontend-web/features/foods/hooks/use-foods.ts index a6ac11c..7178ba6 100644 --- a/frontend-web/features/foods/hooks/use-foods.ts +++ b/frontend-web/features/foods/hooks/use-foods.ts @@ -53,7 +53,8 @@ export function useFoods() { }, []); useEffect(() => { - search(defaultFoodFilters); + const timeoutId = window.setTimeout(() => search(defaultFoodFilters), 0); + return () => window.clearTimeout(timeoutId); }, [search]); const createFood = useCallback( diff --git a/frontend-web/features/ingredients/components/ingredient-form-dialog.tsx b/frontend-web/features/ingredients/components/ingredient-form-dialog.tsx index d2eb096..5d13587 100644 --- a/frontend-web/features/ingredients/components/ingredient-form-dialog.tsx +++ b/frontend-web/features/ingredients/components/ingredient-form-dialog.tsx @@ -32,8 +32,13 @@ export function IngredientFormDialog({ useEffect(() => { if (!open) return; - setForm(ingredient ? ingredientToFormState(ingredient) : emptyIngredientForm()); - setError(null); + const timeoutId = window.setTimeout(() => { + setForm( + ingredient ? ingredientToFormState(ingredient) : emptyIngredientForm(), + ); + setError(null); + }, 0); + return () => window.clearTimeout(timeoutId); }, [ingredient, open]); if (!open) return null; diff --git a/frontend-web/features/ingredients/hooks/use-ingredients.ts b/frontend-web/features/ingredients/hooks/use-ingredients.ts index f78dbe9..a384add 100644 --- a/frontend-web/features/ingredients/hooks/use-ingredients.ts +++ b/frontend-web/features/ingredients/hooks/use-ingredients.ts @@ -53,7 +53,11 @@ export function useIngredients() { ); useEffect(() => { - search(defaultIngredientFilters); + const timeoutId = window.setTimeout( + () => search(defaultIngredientFilters), + 0, + ); + return () => window.clearTimeout(timeoutId); }, [search]); const createIngredient = useCallback( diff --git a/frontend-web/features/jobs/components/admin-job-panel.tsx b/frontend-web/features/jobs/components/admin-job-panel.tsx index df503b6..3b5ca9d 100644 --- a/frontend-web/features/jobs/components/admin-job-panel.tsx +++ b/frontend-web/features/jobs/components/admin-job-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/layout/page-header"; import { jobApi, type JobTriggerResult } from "@/features/jobs/api/admin-job-api"; diff --git a/frontend-web/features/meal-plans/components/distribute-meal-plan-dialog.tsx b/frontend-web/features/meal-plans/components/distribute-meal-plan-dialog.tsx index 9f88f4f..5bedab7 100644 --- a/frontend-web/features/meal-plans/components/distribute-meal-plan-dialog.tsx +++ b/frontend-web/features/meal-plans/components/distribute-meal-plan-dialog.tsx @@ -31,10 +31,12 @@ export function DistributeMealPlanDialog({ const [notes, setNotes] = useState(""); useEffect(() => { - if (plan) { + if (!plan) return; + const timeoutId = window.setTimeout(() => { setTargetAudience("Pro"); setNotes(""); - } + }, 0); + return () => window.clearTimeout(timeoutId); }, [plan]); if (!plan) return null; diff --git a/frontend-web/features/meal-plans/components/meal-plan-form-dialog.tsx b/frontend-web/features/meal-plans/components/meal-plan-form-dialog.tsx index a8c9ab4..7e3a90b 100644 --- a/frontend-web/features/meal-plans/components/meal-plan-form-dialog.tsx +++ b/frontend-web/features/meal-plans/components/meal-plan-form-dialog.tsx @@ -56,16 +56,19 @@ export function MealPlanFormDialog({ useEffect(() => { if (!open) return; - setForm(plan ? mealPlanToFormState(plan) : emptyMealPlanForm()); - setError(null); - - Promise.all([ - foodApi.search({}).then((r) => setFoods(r.items)), - recipeApi.search({}).then((r) => setRecipes(r.items)), - ]).catch(() => { - setFoods([]); - setRecipes([]); - }); + const timeoutId = window.setTimeout(() => { + setForm(plan ? mealPlanToFormState(plan) : emptyMealPlanForm()); + setError(null); + + Promise.all([ + foodApi.search({}).then((r) => setFoods(r.items)), + recipeApi.search({}).then((r) => setRecipes(r.items)), + ]).catch(() => { + setFoods([]); + setRecipes([]); + }); + }, 0); + return () => window.clearTimeout(timeoutId); }, [plan, open]); if (!open) return null; diff --git a/frontend-web/features/meal-plans/hooks/use-meal-plans.ts b/frontend-web/features/meal-plans/hooks/use-meal-plans.ts index f2ec0a5..e4ddf46 100644 --- a/frontend-web/features/meal-plans/hooks/use-meal-plans.ts +++ b/frontend-web/features/meal-plans/hooks/use-meal-plans.ts @@ -38,7 +38,8 @@ export function useMealPlans() { }, [filterActive]); useEffect(() => { - reload(); + const timeoutId = window.setTimeout(reload, 0); + return () => window.clearTimeout(timeoutId); }, [reload]); const createPlan = useCallback( diff --git a/frontend-web/features/migrations/components/admin-migration-panel.tsx b/frontend-web/features/migrations/components/admin-migration-panel.tsx index 7637539..ecc768c 100644 --- a/frontend-web/features/migrations/components/admin-migration-panel.tsx +++ b/frontend-web/features/migrations/components/admin-migration-panel.tsx @@ -13,11 +13,6 @@ export function AdminMigrationPanel() { applied: string[]; pending: string[]; } | null>(null); - const [history, setHistory] = useState>([]); const [loading, setLoading] = useState(true); const [applying, setApplying] = useState(false); const [error, setError] = useState(null); @@ -37,7 +32,8 @@ export function AdminMigrationPanel() { }, []); useEffect(() => { - fetchStatus(); + const timeoutId = window.setTimeout(fetchStatus, 0); + return () => window.clearTimeout(timeoutId); }, [fetchStatus]); async function handleApply() { diff --git a/frontend-web/features/notifications/components/admin-notification-panel.tsx b/frontend-web/features/notifications/components/admin-notification-panel.tsx index b43db8b..73dc1ae 100644 --- a/frontend-web/features/notifications/components/admin-notification-panel.tsx +++ b/frontend-web/features/notifications/components/admin-notification-panel.tsx @@ -32,7 +32,8 @@ export function AdminNotificationPanel() { }, []); useEffect(() => { - fetchStats(); + const timeoutId = window.setTimeout(fetchStats, 0); + return () => window.clearTimeout(timeoutId); }, [fetchStats]); async function handleDispatch() { diff --git a/frontend-web/features/recipes/components/recipe-form-dialog.tsx b/frontend-web/features/recipes/components/recipe-form-dialog.tsx index 3635e97..6481ca1 100644 --- a/frontend-web/features/recipes/components/recipe-form-dialog.tsx +++ b/frontend-web/features/recipes/components/recipe-form-dialog.tsx @@ -38,10 +38,16 @@ export function RecipeFormDialog({ useEffect(() => { if (!open) return; - setForm(recipe ? recipeToFormState(recipe) : emptyRecipeForm()); - setError(null); - - ingredientApi.search({}).then((result) => setIngredientOptions(result.items)).catch(() => setIngredientOptions([])); + const timeoutId = window.setTimeout(() => { + setForm(recipe ? recipeToFormState(recipe) : emptyRecipeForm()); + setError(null); + + ingredientApi + .search({}) + .then((result) => setIngredientOptions(result.items)) + .catch(() => setIngredientOptions([])); + }, 0); + return () => window.clearTimeout(timeoutId); }, [recipe, open]); if (!open) return null; diff --git a/frontend-web/features/recipes/hooks/use-recipes.ts b/frontend-web/features/recipes/hooks/use-recipes.ts index ec4914b..2328d4b 100644 --- a/frontend-web/features/recipes/hooks/use-recipes.ts +++ b/frontend-web/features/recipes/hooks/use-recipes.ts @@ -55,7 +55,8 @@ export function useRecipes() { ); useEffect(() => { - search(defaultRecipeFilters); + const timeoutId = window.setTimeout(() => search(defaultRecipeFilters), 0); + return () => window.clearTimeout(timeoutId); }, [search]); const createRecipe = useCallback( diff --git a/frontend-web/features/subscription-plans/components/subscription-plan-form-dialog.tsx b/frontend-web/features/subscription-plans/components/subscription-plan-form-dialog.tsx index c80b16d..84367ff 100644 --- a/frontend-web/features/subscription-plans/components/subscription-plan-form-dialog.tsx +++ b/frontend-web/features/subscription-plans/components/subscription-plan-form-dialog.tsx @@ -37,8 +37,11 @@ export function SubscriptionPlanFormDialog({ useEffect(() => { if (!open) return; - setForm(plan ? planToFormState(plan) : emptySubscriptionPlanForm()); - setError(null); + const timeoutId = window.setTimeout(() => { + setForm(plan ? planToFormState(plan) : emptySubscriptionPlanForm()); + setError(null); + }, 0); + return () => window.clearTimeout(timeoutId); }, [plan, open]); if (!open) return null; diff --git a/frontend-web/features/subscription-plans/hooks/use-subscription-plans.ts b/frontend-web/features/subscription-plans/hooks/use-subscription-plans.ts index 8ee5ce4..b1f4229 100644 --- a/frontend-web/features/subscription-plans/hooks/use-subscription-plans.ts +++ b/frontend-web/features/subscription-plans/hooks/use-subscription-plans.ts @@ -37,7 +37,8 @@ export function useSubscriptionPlans() { }, [filterActive]); useEffect(() => { - reload(); + const timeoutId = window.setTimeout(reload, 0); + return () => window.clearTimeout(timeoutId); }, [reload]); const createPlan = useCallback( diff --git a/frontend-web/features/users/api/admin-user-api.ts b/frontend-web/features/users/api/admin-user-api.ts index 172a0f9..952b702 100644 --- a/frontend-web/features/users/api/admin-user-api.ts +++ b/frontend-web/features/users/api/admin-user-api.ts @@ -1,6 +1,10 @@ import { apiClient } from "@/lib/api/client"; import { apiEndpoints } from "@/lib/api/endpoints"; -import type { UserAdmin } from "@/features/users/types"; +import type { + AdminGrantMembershipRequest, + AdminUserMembership, + UserAdmin, +} from "@/features/users/types"; export const adminUserApi = { getAll(): Promise { @@ -18,4 +22,26 @@ export const adminUserApi = { unlock(id: string): Promise { return apiClient.patch(apiEndpoints.adminUser.unlock(id)); }, + + getMemberships(id: string): Promise { + return apiClient.get(apiEndpoints.adminUser.memberships(id)); + }, + + grantMembership(id: string, payload: AdminGrantMembershipRequest): Promise { + return apiClient.post(apiEndpoints.adminUser.memberships(id), payload); + }, + + extendMembership(id: string, subscriptionId: string, durationDays: number, note?: string): Promise { + return apiClient.post( + apiEndpoints.adminUser.extendMembership(id, subscriptionId), + { durationDays, note }, + ); + }, + + revokeMembership(id: string, subscriptionId: string, reason: string): Promise { + return apiClient.post( + apiEndpoints.adminUser.revokeMembership(id, subscriptionId), + { reason }, + ); + }, }; diff --git a/frontend-web/features/users/components/manage-membership-dialog.tsx b/frontend-web/features/users/components/manage-membership-dialog.tsx new file mode 100644 index 0000000..432855c --- /dev/null +++ b/frontend-web/features/users/components/manage-membership-dialog.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { adminUserApi } from "@/features/users/api/admin-user-api"; +import { subscriptionPlanApi } from "@/features/subscription-plans/api/subscription-plan-api"; +import type { SubscriptionPlan } from "@/features/subscription-plans/types"; +import type { AdminUserMembership, UserAdmin } from "@/features/users/types"; +import { getErrorMessage } from "@/lib/api/errors"; +import { formatDateTime } from "@/lib/utils/format"; + +interface ManageMembershipDialogProps { + user: UserAdmin | null; + onClose: () => void; + onChanged: () => Promise; +} + +export function ManageMembershipDialog({ user, onClose, onChanged }: ManageMembershipDialogProps) { + const [membership, setMembership] = useState(null); + const [plans, setPlans] = useState([]); + const [planId, setPlanId] = useState(""); + const [durationDays, setDurationDays] = useState(30); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + + const grantablePlans = useMemo( + () => plans.filter((plan) => { + const group = plan.featureGroup?.trim().toLowerCase(); + return plan.isActive && group !== "free" && group !== "basic"; + }), + [plans], + ); + + useEffect(() => { + if (!user) return; + let active = true; + Promise.all([ + adminUserApi.getMemberships(user.id), + subscriptionPlanApi.getAll({ isActive: true }), + ]) + .then(([membershipData, planData]) => { + if (!active) return; + setMembership(membershipData); + setPlans(planData); + const first = planData.find((plan) => { + const group = plan.featureGroup?.trim().toLowerCase(); + return plan.isActive && group !== "free" && group !== "basic"; + }); + setPlanId(first?.id ?? ""); + setDurationDays(first?.durationDays || 30); + }) + .catch((err) => active && setError(getErrorMessage(err, "Không thể tải thông tin gói"))) + .finally(() => active && setBusy(false)); + return () => { active = false; }; + }, [user]); + + if (!user) return null; + + async function grant() { + if (!planId || durationDays < 1) return; + setBusy(true); + setError(null); + try { + const result = await adminUserApi.grantMembership(user!.id, { + subscriptionPlanId: planId, + durationDays, + note: note.trim() || undefined, + }); + setMembership(result); + setNote(""); + await onChanged(); + } catch (err) { + setError(getErrorMessage(err, "Không thể cấp gói")); + } finally { + setBusy(false); + } + } + + async function extend(subscriptionId: string) { + setBusy(true); + setError(null); + try { + const result = await adminUserApi.extendMembership( + user!.id, + subscriptionId, + durationDays, + note.trim() || undefined, + ); + setMembership(result); + setNote(""); + await onChanged(); + } catch (err) { + setError(getErrorMessage(err, "Không thể gia hạn gói")); + } finally { + setBusy(false); + } + } + + async function revoke(subscriptionId: string, planName: string) { + const reason = window.prompt(`Lý do thu hồi gói ${planName}:`); + if (!reason?.trim()) return; + setBusy(true); + setError(null); + try { + const result = await adminUserApi.revokeMembership(user!.id, subscriptionId, reason.trim()); + setMembership(result); + await onChanged(); + } catch (err) { + setError(getErrorMessage(err, "Không thể thu hồi gói")); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
+

Quản lý gói thành viên

+

{user.fullName || "Chưa có tên"} — {user.email}

+
+ +
+ + {error ?
{error}
: null} + +
+
Quyền hiện tại
{membership && membership.tier !== "free" ? membership.tier : "Cơ bản"}
+
Hết hạn gần nhất
{membership?.expiresAt ? formatDateTime(membership.expiresAt) : "Không có"}
+
Entitlements
{membership?.entitlements.join(", ") || "free_features"}
+
+ +
+

Cấp gói mới

+
+ + setDurationDays(Number(event.target.value))} /> +
+