feat: Admin user management + SaaS limits tested v1.51.0

- Add UserManagementController@store for creating users
- Add POST /api/admin/users endpoint
- Support user types: Free, Pro, Admin
- Auto-create 100-year subscription for Pro/Admin users
- Add user creation modal to Users.jsx
- Complete SaaS limit testing:
  - Free user limits: 1 account, 10 categories, 3 budgets, 100 tx
  - Middleware blocks correctly at limits
  - Error messages are user-friendly
  - Usage stats API working correctly
- Update SAAS_STATUS.md with test results
- Bump version to 1.51.0
This commit is contained in:
marcoitaloesp-ai 2025-12-17 15:22:01 +00:00 committed by GitHub
parent 679a1bc4b2
commit 3a336eb692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1953 additions and 76 deletions

View File

@ -5,6 +5,40 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.51.0] - 2025-12-17
### Added
- 👥 **Gestão de Usuários Admin** - Interface completa para administradores criarem usuários
- **Backend**:
- `UserManagementController@store`: Novo endpoint para criar usuários
- Rota `POST /api/admin/users` para criação
- Suporte a tipos de usuário: Free, Pro, Admin
- Assinatura automática Pro/Admin (100 anos, sem PayPal)
- **Frontend**:
- Modal de criação de usuário em `/users`
- Seleção de tipo: Free (sem assinatura), Pro (com assinatura), Admin (admin + assinatura)
- Feedback visual após criação
### Tested
- 🧪 **Testes Completos do SaaS** - Validação do sistema de limites
- **Usuário Free testfree2@webmoney.test (ID: 4)**:
- ✅ Limite de contas: 1/1 → Bloqueia corretamente
- ✅ Limite de categorias: 10/10 → Bloqueia corretamente
- ✅ Limite de budgets: 3/3 → Bloqueia corretamente
- ✅ Mensagens de erro amigáveis em espanhol
- ✅ API retorna `usage` e `usage_percentages` corretos
- **Usuário Pro (admin)**:
- ✅ Limites `null` = ilimitado
- ✅ 173 categorias, 1204 transações sem bloqueio
### Technical Details
- Middleware `CheckPlanLimits` validado em produção
- Endpoint `/subscription/status` retorna uso atual corretamente
- Widget `PlanUsageWidget` funcional no Dashboard
---
## [1.50.0] - 2025-12-17 ## [1.50.0] - 2025-12-17
### Added ### Added

View File

@ -1 +1 @@
1.50.0 1.51.0

View File

@ -0,0 +1,185 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use App\Services\PayPalService;
use Illuminate\Console\Command;
class SetupPayPalPlans extends Command
{
protected $signature = 'paypal:setup-plans';
protected $description = 'Create products and billing plans in PayPal for all active plans';
public function handle()
{
$this->info('🚀 Starting PayPal Plans Setup...');
$this->newLine();
$paypal = new PayPalService();
// Test authentication first
$this->info('Testing PayPal authentication...');
try {
$token = $paypal->getAccessToken();
$this->info('✅ PayPal authentication successful!');
$this->newLine();
} catch (\Exception $e) {
$this->error('❌ PayPal authentication failed: ' . $e->getMessage());
return 1;
}
// Get plans that don't have PayPal plan IDs yet
$plans = Plan::where('is_active', true)
->where('is_free', false)
->get();
if ($plans->isEmpty()) {
$this->warn('No paid plans found in database.');
return 0;
}
$this->info("Found {$plans->count()} paid plan(s) to setup in PayPal:");
$this->newLine();
foreach ($plans as $plan) {
$this->info("📦 Processing: {$plan->name}");
// Check if already has PayPal plan ID
if ($plan->paypal_plan_id) {
$this->warn(" Already has PayPal Plan ID: {$plan->paypal_plan_id}");
$this->line(" Skipping...");
$this->newLine();
continue;
}
try {
// Step 1: Create Product in PayPal
$this->line(" Creating product in PayPal...");
$product = $this->createProduct($paypal, $plan);
$this->info(" ✅ Product created: {$product['id']}");
// Step 2: Create Billing Plan in PayPal
$this->line(" Creating billing plan in PayPal...");
$billingPlan = $this->createBillingPlan($paypal, $plan, $product['id']);
$this->info(" ✅ Billing Plan created: {$billingPlan['id']}");
// Step 3: Save PayPal Plan ID to database
$plan->paypal_plan_id = $billingPlan['id'];
$plan->save();
$this->info(" ✅ Saved to database!");
$this->newLine();
} catch (\Exception $e) {
$this->error(" ❌ Error: " . $e->getMessage());
$this->newLine();
}
}
$this->info('🎉 PayPal Plans Setup completed!');
$this->newLine();
// Show summary
$this->table(
['Plan', 'Price', 'Billing', 'PayPal Plan ID'],
Plan::where('is_free', false)->get()->map(function ($p) {
return [
$p->name,
'€' . number_format($p->price, 2),
$p->billing_period,
$p->paypal_plan_id ?? 'Not set'
];
})
);
return 0;
}
private function createProduct(PayPalService $paypal, Plan $plan): array
{
$baseUrl = config('services.paypal.mode') === 'sandbox'
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
$response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken())
->post("{$baseUrl}/v1/catalogs/products", [
'name' => "WEBMoney - {$plan->name}",
'description' => $plan->description ?? "Subscription plan for WEBMoney",
'type' => 'SERVICE',
'category' => 'SOFTWARE',
'home_url' => config('app.url'),
]);
if (!$response->successful()) {
throw new \Exception('Failed to create product: ' . $response->body());
}
return $response->json();
}
private function createBillingPlan(PayPalService $paypal, Plan $plan, string $productId): array
{
$baseUrl = config('services.paypal.mode') === 'sandbox'
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
$billingCycles = [];
// Add trial period if plan has trial
if ($plan->trial_days > 0) {
$billingCycles[] = [
'frequency' => [
'interval_unit' => 'DAY',
'interval_count' => $plan->trial_days,
],
'tenure_type' => 'TRIAL',
'sequence' => 1,
'total_cycles' => 1,
'pricing_scheme' => [
'fixed_price' => [
'value' => '0',
'currency_code' => $plan->currency,
],
],
];
}
// Regular billing cycle
$billingCycles[] = [
'frequency' => [
'interval_unit' => $plan->billing_period === 'annual' ? 'YEAR' : 'MONTH',
'interval_count' => 1,
],
'tenure_type' => 'REGULAR',
'sequence' => $plan->trial_days > 0 ? 2 : 1,
'total_cycles' => 0, // Infinite
'pricing_scheme' => [
'fixed_price' => [
'value' => number_format($plan->price, 2, '.', ''),
'currency_code' => $plan->currency,
],
],
];
$response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken())
->post("{$baseUrl}/v1/billing/plans", [
'product_id' => $productId,
'name' => $plan->name,
'description' => $plan->description ?? "WEBMoney {$plan->name} subscription",
'status' => 'ACTIVE',
'billing_cycles' => $billingCycles,
'payment_preferences' => [
'auto_bill_outstanding' => true,
'setup_fee_failure_action' => 'CONTINUE',
'payment_failure_threshold' => 3,
],
]);
if (!$response->successful()) {
throw new \Exception('Failed to create billing plan: ' . $response->body());
}
return $response->json();
}
}

View File

@ -30,6 +30,26 @@ public function status(Request $request): JsonResponse
$subscription = $user->subscriptions()->active()->with('plan')->first(); $subscription = $user->subscriptions()->active()->with('plan')->first();
$currentPlan = $user->currentPlan(); $currentPlan = $user->currentPlan();
// Get current usage
$usage = [
'accounts' => $user->accounts()->count(),
'categories' => $user->categories()->count(),
'budgets' => $user->budgets()->count(),
'transactions' => $user->transactions()->count(),
];
// Calculate usage percentages if plan has limits
$limits = $currentPlan?->limits ?? [];
$usagePercentages = [];
foreach ($usage as $resource => $count) {
$limit = $limits[$resource] ?? null;
if ($limit !== null && $limit > 0) {
$usagePercentages[$resource] = round(($count / $limit) * 100, 1);
} else {
$usagePercentages[$resource] = null; // unlimited
}
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
@ -53,6 +73,8 @@ public function status(Request $request): JsonResponse
'features' => $currentPlan->features, 'features' => $currentPlan->features,
'limits' => $currentPlan->limits, 'limits' => $currentPlan->limits,
] : null, ] : null,
'usage' => $usage,
'usage_percentages' => $usagePercentages,
], ],
]); ]);
} }
@ -352,6 +374,12 @@ public function paypalConfig(): JsonResponse
*/ */
public function webhook(Request $request): JsonResponse public function webhook(Request $request): JsonResponse
{ {
// Log all incoming webhooks for debugging
Log::channel('single')->info('=== PAYPAL WEBHOOK RECEIVED ===', [
'event_type' => $request->input('event_type'),
'content' => $request->all(),
]);
$webhookId = config('services.paypal.webhook_id'); $webhookId = config('services.paypal.webhook_id');
// Verify webhook signature (skip in sandbox for testing) // Verify webhook signature (skip in sandbox for testing)

View File

@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Plan;
use App\Models\Subscription;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Carbon\Carbon;
class UserManagementController extends Controller
{
/**
* Create a new user with specified role/plan
* user_type: 'free' | 'pro' | 'admin'
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'sometimes|string|min:8',
'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3',
'user_type' => 'sometimes|string|in:free,pro,admin',
]);
// Generate random password if not provided
$password = $validated['password'] ?? bin2hex(random_bytes(8));
$userType = $validated['user_type'] ?? 'free';
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($password),
'language' => $validated['language'] ?? 'es',
'currency' => $validated['currency'] ?? 'EUR',
'email_verified_at' => now(), // Auto-verify admin-created users
'is_admin' => $userType === 'admin',
]);
$subscriptionInfo = null;
// Create Pro subscription if user_type is 'pro' or 'admin'
if (in_array($userType, ['pro', 'admin'])) {
$proPlan = Plan::where('slug', 'pro-annual')->first();
if ($proPlan) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $proPlan->id,
'status' => Subscription::STATUS_ACTIVE,
'current_period_start' => now(),
'current_period_end' => now()->addYears(100), // "Lifetime" subscription
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
'paypal_status' => 'ACTIVE',
'price_paid' => 0,
'currency' => 'EUR',
]);
$subscriptionInfo = [
'plan' => $proPlan->name,
'status' => 'active',
'expires' => 'Nunca (otorgado por admin)',
];
}
}
return response()->json([
'success' => true,
'message' => 'Usuario creado correctamente',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_admin' => $user->is_admin,
],
'user_type' => $userType,
'subscription' => $subscriptionInfo,
'temporary_password' => isset($validated['password']) ? null : $password,
],
], 201);
}
/**
* List all users with their subscription info
*/
public function index(Request $request)
{
$query = User::with(['subscription.plan'])
->withCount(['accounts', 'categories', 'budgets', 'transactions']);
// Search
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by subscription status
if ($request->has('subscription_status')) {
if ($request->subscription_status === 'active') {
$query->whereHas('subscription');
} elseif ($request->subscription_status === 'free') {
$query->whereDoesntHave('subscription');
}
}
// Pagination
$perPage = $request->get('per_page', 20);
$users = $query->orderBy('created_at', 'desc')->paginate($perPage);
// Transform data
$users->getCollection()->transform(function ($user) {
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'created_at' => $user->created_at,
'last_login_at' => $user->last_login_at,
'email_verified_at' => $user->email_verified_at,
'subscription' => $user->subscription ? [
'plan_name' => $user->subscription->plan->name ?? 'Unknown',
'plan_slug' => $user->subscription->plan->slug ?? 'unknown',
'status' => $user->subscription->status,
'current_period_end' => $user->subscription->current_period_end,
] : null,
'usage' => [
'accounts' => $user->accounts_count,
'categories' => $user->categories_count,
'budgets' => $user->budgets_count,
'transactions' => $user->transactions_count,
],
];
});
return response()->json([
'success' => true,
'data' => $users->items(),
'pagination' => [
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
'per_page' => $users->perPage(),
'total' => $users->total(),
],
]);
}
/**
* Get single user details
*/
public function show($id)
{
$user = User::with(['subscription.plan', 'subscriptions.plan'])
->withCount(['accounts', 'categories', 'budgets', 'transactions', 'goals'])
->findOrFail($id);
return response()->json([
'success' => true,
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'language' => $user->language,
'currency' => $user->currency,
'created_at' => $user->created_at,
'last_login_at' => $user->last_login_at,
'email_verified_at' => $user->email_verified_at,
'subscription' => $user->subscription ? [
'id' => $user->subscription->id,
'plan' => $user->subscription->plan,
'status' => $user->subscription->status,
'paypal_subscription_id' => $user->subscription->paypal_subscription_id,
'current_period_start' => $user->subscription->current_period_start,
'current_period_end' => $user->subscription->current_period_end,
'canceled_at' => $user->subscription->canceled_at,
] : null,
'subscription_history' => $user->subscriptions->map(function ($sub) {
return [
'plan_name' => $sub->plan->name ?? 'Unknown',
'status' => $sub->status,
'created_at' => $sub->created_at,
'canceled_at' => $sub->canceled_at,
];
}),
'usage' => [
'accounts' => $user->accounts_count,
'categories' => $user->categories_count,
'budgets' => $user->budgets_count,
'transactions' => $user->transactions_count,
'goals' => $user->goals_count,
],
],
]);
}
/**
* Update user
*/
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $id,
'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3',
]);
$user->update($validated);
return response()->json([
'success' => true,
'message' => 'Usuario actualizado correctamente',
'data' => $user,
]);
}
/**
* Reset user password (generate random)
*/
public function resetPassword($id)
{
$user = User::findOrFail($id);
// Generate random password
$newPassword = bin2hex(random_bytes(8)); // 16 chars
$user->password = Hash::make($newPassword);
$user->save();
return response()->json([
'success' => true,
'message' => 'Contraseña restablecida correctamente',
'data' => [
'temporary_password' => $newPassword,
],
]);
}
/**
* Delete user and all their data
*/
public function destroy($id)
{
$user = User::findOrFail($id);
// Don't allow deleting admin
if ($user->email === 'marco@cnxifly.com') {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar el usuario administrador',
], 403);
}
// Delete user (cascade will handle related data)
$user->delete();
return response()->json([
'success' => true,
'message' => 'Usuario eliminado correctamente',
]);
}
/**
* Get summary statistics
*/
public function summary()
{
$totalUsers = User::count();
$activeSubscribers = Subscription::where('status', 'active')->count();
$freeUsers = $totalUsers - $activeSubscribers;
$newUsersThisMonth = User::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
return response()->json([
'success' => true,
'data' => [
'total_users' => $totalUsers,
'active_subscribers' => $activeSubscribers,
'free_users' => $freeUsers,
'new_users_this_month' => $newUsersThisMonth,
],
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminOnly
{
/**
* Admin email - only this user can access restricted features
*/
private const ADMIN_EMAIL = 'marco@cnxifly.com';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || $user->email !== self::ADMIN_EMAIL) {
return response()->json([
'success' => false,
'message' => 'Access denied. This feature is not available.',
], 403);
}
return $next($request);
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckPlanLimits
{
/**
* Resource type to limit mapping
*/
protected array $resourceLimits = [
'accounts' => 'accounts',
'categories' => 'categories',
'budgets' => 'budgets',
'transactions' => 'transactions',
'goals' => 'goals',
];
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string $resource): Response
{
// Only check on store (create) requests
if (!in_array($request->method(), ['POST'])) {
return $next($request);
}
$user = $request->user();
if (!$user) {
return $next($request);
}
$plan = $user->currentPlan();
if (!$plan) {
return $next($request);
}
$limits = $plan->limits ?? [];
$limitKey = $this->resourceLimits[$resource] ?? null;
if (!$limitKey || !isset($limits[$limitKey])) {
return $next($request);
}
$limit = $limits[$limitKey];
// null means unlimited
if ($limit === null) {
return $next($request);
}
$currentCount = $this->getCurrentCount($user, $resource);
if ($currentCount >= $limit) {
return response()->json([
'success' => false,
'message' => $this->getLimitMessage($resource, $limit),
'error' => 'plan_limit_exceeded',
'data' => [
'resource' => $resource,
'current' => $currentCount,
'limit' => $limit,
'plan' => $plan->name,
'upgrade_url' => '/pricing',
],
], 403);
}
return $next($request);
}
/**
* Get current count for a resource
*/
protected function getCurrentCount($user, string $resource): int
{
return match ($resource) {
'accounts' => $user->accounts()->count(),
'categories' => $user->categories()->count(),
'budgets' => $user->budgets()->count(),
'transactions' => $user->transactions()->count(),
'goals' => $user->goals()->count(),
default => 0,
};
}
/**
* Get user-friendly limit message
*/
protected function getLimitMessage(string $resource, int $limit): string
{
$messages = [
'accounts' => "Has alcanzado el límite de {$limit} cuenta(s) de tu plan. Actualiza a Pro para cuentas ilimitadas.",
'categories' => "Has alcanzado el límite de {$limit} categorías de tu plan. Actualiza a Pro para categorías ilimitadas.",
'budgets' => "Has alcanzado el límite de {$limit} presupuesto(s) de tu plan. Actualiza a Pro para presupuestos ilimitados.",
'transactions' => "Has alcanzado el límite de {$limit} transacciones de tu plan. Actualiza a Pro para transacciones ilimitadas.",
'goals' => "Has alcanzado el límite de {$limit} meta(s) de tu plan. Actualiza a Pro para metas ilimitadas.",
];
return $messages[$resource] ?? "Has alcanzado el límite de tu plan para este recurso.";
}
}

View File

@ -22,6 +22,7 @@ class Plan extends Model
'limits', 'limits',
'is_active', 'is_active',
'is_featured', 'is_featured',
'is_free',
'sort_order', 'sort_order',
'paypal_plan_id', 'paypal_plan_id',
]; ];
@ -32,6 +33,7 @@ class Plan extends Model
'limits' => 'array', 'limits' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'is_featured' => 'boolean', 'is_featured' => 'boolean',
'is_free' => 'boolean',
'trial_days' => 'integer', 'trial_days' => 'integer',
'sort_order' => 'integer', 'sort_order' => 'integer',
]; ];

View File

@ -91,6 +91,48 @@ public function isAdmin(): bool
return $this->is_admin === true; return $this->is_admin === true;
} }
// ==================== RESOURCE RELATIONSHIPS ====================
/**
* Get all accounts for the user
*/
public function accounts()
{
return $this->hasMany(Account::class);
}
/**
* Get all categories for the user
*/
public function categories()
{
return $this->hasMany(Category::class);
}
/**
* Get all budgets for the user
*/
public function budgets()
{
return $this->hasMany(Budget::class);
}
/**
* Get all transactions for the user
*/
public function transactions()
{
return $this->hasMany(Transaction::class);
}
/**
* Get all goals for the user
*/
public function goals()
{
return $this->hasMany(FinancialGoal::class);
}
// ==================== SUBSCRIPTION RELATIONSHIPS ==================== // ==================== SUBSCRIPTION RELATIONSHIPS ====================
/** /**

View File

@ -9,8 +9,8 @@
class PayPalService class PayPalService
{ {
private string $clientId; private ?string $clientId;
private string $clientSecret; private ?string $clientSecret;
private string $baseUrl; private string $baseUrl;
private bool $sandbox; private bool $sandbox;
@ -29,6 +29,11 @@ public function __construct()
*/ */
public function getAccessToken(): ?string public function getAccessToken(): ?string
{ {
if (!$this->isConfigured()) {
Log::warning('PayPal not configured');
return null;
}
return Cache::remember('paypal_access_token', 28800, function () { return Cache::remember('paypal_access_token', 28800, function () {
try { try {
$response = Http::withBasicAuth($this->clientId, $this->clientSecret) $response = Http::withBasicAuth($this->clientId, $this->clientSecret)
@ -349,7 +354,7 @@ public function isConfigured(): bool
/** /**
* Get client ID for frontend * Get client ID for frontend
*/ */
public function getClientId(): string public function getClientId(): ?string
{ {
return $this->clientId; return $this->clientId;
} }

View File

@ -18,6 +18,8 @@
// Alias para rate limiting // Alias para rate limiting
$middleware->alias([ $middleware->alias([
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class,
'admin.only' => \App\Http\Middleware\AdminOnly::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@ -24,6 +24,7 @@
use App\Http\Controllers\Api\UserPreferenceController; use App\Http\Controllers\Api\UserPreferenceController;
use App\Http\Controllers\Api\PlanController; use App\Http\Controllers\Api\PlanController;
use App\Http\Controllers\Api\SubscriptionController; use App\Http\Controllers\Api\SubscriptionController;
use App\Http\Controllers\Api\UserManagementController;
// Public routes with rate limiting // Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
@ -63,15 +64,16 @@
Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']); Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']);
// ============================================ // ============================================
// Contas (Accounts) // Contas (Accounts) - Com limite de plano
// ============================================ // ============================================
// Rotas específicas ANTES do apiResource // Rotas específicas ANTES do apiResource
Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']); Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']);
Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']); Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']);
Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']); Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']);
// Resource principal // Resource principal com middleware de limite no store
Route::apiResource('accounts', AccountController::class); Route::post('accounts', [AccountController::class, 'store'])->middleware('plan.limits:accounts');
Route::apiResource('accounts', AccountController::class)->except(['store']);
Route::get('accounts-total', [AccountController::class, 'totalBalance']); Route::get('accounts-total', [AccountController::class, 'totalBalance']);
// ============================================ // ============================================
@ -83,7 +85,7 @@
Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']); Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']);
// ============================================ // ============================================
// Categorias (Categories) // Categorias (Categories) - Com limite de plano
// ============================================ // ============================================
// Rotas específicas ANTES do apiResource // Rotas específicas ANTES do apiResource
Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']); Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']);
@ -92,8 +94,9 @@
Route::post('categories/match', [CategoryController::class, 'matchByText']); Route::post('categories/match', [CategoryController::class, 'matchByText']);
Route::post('categories/reorder', [CategoryController::class, 'reorder']); Route::post('categories/reorder', [CategoryController::class, 'reorder']);
// Resource principal // Resource principal com middleware de limite no store
Route::apiResource('categories', CategoryController::class); Route::post('categories', [CategoryController::class, 'store'])->middleware('plan.limits:categories');
Route::apiResource('categories', CategoryController::class)->except(['store']);
// Rotas com parâmetros (depois do apiResource) // Rotas com parâmetros (depois do apiResource)
Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']); Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']);
@ -120,9 +123,10 @@
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']); Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']);
// ============================================ // ============================================
// Transações (Transactions) // Transações (Transactions) - Com limite de plano
// ============================================ // ============================================
Route::apiResource('transactions', TransactionController::class); Route::post('transactions', [TransactionController::class, 'store'])->middleware('plan.limits:transactions');
Route::apiResource('transactions', TransactionController::class)->except(['store']);
Route::get('transactions-by-week', [TransactionController::class, 'byWeek']); Route::get('transactions-by-week', [TransactionController::class, 'byWeek']);
Route::get('transactions-summary', [TransactionController::class, 'summary']); Route::get('transactions-summary', [TransactionController::class, 'summary']);
@ -222,43 +226,53 @@
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']); Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
// ============================================ // ============================================
// Business - Precificação de Produtos // Business - Precificação de Produtos (Admin Only)
// ============================================ // ============================================
Route::middleware('admin.only')->group(function () {
// User Management (Admin Only)
Route::get('admin/users/summary', [UserManagementController::class, 'summary']);
Route::get('admin/users', [UserManagementController::class, 'index']);
Route::post('admin/users', [UserManagementController::class, 'store']);
Route::get('admin/users/{id}', [UserManagementController::class, 'show']);
Route::put('admin/users/{id}', [UserManagementController::class, 'update']);
Route::post('admin/users/{id}/reset-password', [UserManagementController::class, 'resetPassword']);
Route::delete('admin/users/{id}', [UserManagementController::class, 'destroy']);
// Configurações de Negócio (Markup)
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
Route::apiResource('business-settings', BusinessSettingController::class);
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
// Fichas Técnicas de Produtos (CMV)
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
Route::apiResource('product-sheets', ProductSheetController::class);
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
// Configurações de Negócio (Markup) // Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']); Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
Route::apiResource('business-settings', BusinessSettingController::class); Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']); Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']); Route::apiResource('service-sheets', ServiceSheetController::class);
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
// Fichas Técnicas de Produtos (CMV) // Campanhas Promocionais (Black Friday, etc.)
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']); Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']); Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
Route::apiResource('product-sheets', ProductSheetController::class); Route::apiResource('campaigns', PromotionalCampaignController::class);
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']); Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']); Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']); Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']); Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']); }); // End admin.only group
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
Route::apiResource('service-sheets', ServiceSheetController::class);
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
// Campanhas Promocionais (Black Friday, etc.)
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
Route::apiResource('campaigns', PromotionalCampaignController::class);
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
// ============================================ // ============================================
// Metas Financieras (Financial Goals) // Metas Financieras (Financial Goals)
@ -268,12 +282,13 @@
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']); Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
// ============================================ // ============================================
// Presupuestos (Budgets) // Presupuestos (Budgets) - Com limite de plano
// ============================================ // ============================================
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']); Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']); Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']); Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
Route::apiResource('budgets', BudgetController::class); Route::post('budgets', [BudgetController::class, 'store'])->middleware('plan.limits:budgets');
Route::apiResource('budgets', BudgetController::class)->except(['store']);
// ============================================ // ============================================
// Reportes (Reports) // Reportes (Reports)

129
docs/SAAS_STATUS.md Normal file
View File

@ -0,0 +1,129 @@
# WebMoney SaaS - Status de Implementação
> Última atualização: 17 de Dezembro de 2025
## ✅ Concluído
### Fase 1: Estrutura de Planos
- [x] Tabela `plans` com 3 planos (Free, Pro Mensual, Pro Anual)
- [x] Tabela `subscriptions` para gerenciar assinaturas
- [x] Model Plan com relacionamentos
- [x] Model Subscription com métodos auxiliares
### Fase 2: Integração PayPal
- [x] PayPalService completo (criar/cancelar/reativar assinaturas)
- [x] Credenciais PayPal Sandbox configuradas no servidor
- [x] Planos criados no PayPal com IDs salvos no banco
- [x] FRONTEND_URL configurado para redirects
### Fase 3: Fluxo de Assinatura
- [x] Endpoint `GET /api/plans` - listar planos
- [x] Endpoint `POST /api/subscription/create` - criar assinatura
- [x] Endpoint `GET /api/subscription/status` - status atual
- [x] Endpoint `POST /api/subscription/cancel` - cancelar
- [x] Endpoint `POST /api/subscription/reactivate` - reativar
- [x] Webhooks PayPal funcionando (ID: 4UM53122EW59785)
- [x] Frontend: página de planos e checkout
- [x] Assinatura de teste criada: I-RHE4CFSL3T3N (Pro Mensual)
### Fase 4: Enforcement de Limites
- [x] Limites do plano Free definidos:
- 1 conta bancária
- 10 categorias
- 3 orçamentos
- 100 transações
- [x] Middleware `CheckPlanLimits` criado
- [x] Middleware aplicado nas rotas de criação
- [x] Endpoint `/subscription/status` retorna uso atual
- [x] Widget `PlanUsageWidget` no Dashboard
- [x] Translations (es.json, pt-BR.json)
## ✅ Testes Realizados (17/12/2025)
### Testar com Usuário Free
- [x] Criar usuário sem assinatura ✅ **FUNCIONA**
- Usuário de teste: `testfree2@webmoney.test` (ID: 4)
- Plano Free atribuído automaticamente
- [x] Validar que middleware bloqueia ao atingir limite ✅ **FUNCIONA**
- Contas: 1/1 → Bloqueia 2ª conta ✅
- Categorias: 10/10 → Bloqueia 11ª categoria ✅
- Budgets: 3/3 → Bloqueia 4º orçamento ✅
- [x] Validar widget mostra progresso corretamente ✅ **FUNCIONA**
- API retorna `usage` e `usage_percentages` corretos
- [x] Testar mensagens de erro amigáveis ✅ **FUNCIONA**
- Mensagem: "Has alcanzado el límite de X de tu plan. Actualiza a Pro para X ilimitados."
- Retorna `error: plan_limit_exceeded` com dados do limite
### Testar Usuário Pro
- [x] Admin Pro tem limites `null` (ilimitados) ✅
- [x] 173 categorias, 1204 transações sem bloqueio ✅
### Testar Criação de Usuários Admin
- [x] Endpoint POST `/api/admin/users` ✅ **FUNCIONA**
- [x] Tipos de usuário: Free, Pro, Admin ✅
- [x] Pro/Admin recebem assinatura automática de 100 anos ✅
### ⏳ Pendente - Testes de Cancelamento
- [ ] Cancelar assinatura via PayPal
- [ ] Verificar grace period funciona
- [ ] Verificar downgrade para Free após expirar
### ⏳ Pendente - Testes de Reativação
- [ ] Reativar assinatura cancelada via PayPal
- [ ] Verificar status volta para ativo
## 🔧 Melhorias Futuras (Produção)
| Prioridade | Item | Descrição |
|------------|------|-----------|
| **Alta** | PayPal Live | Trocar credenciais sandbox para produção |
| **Alta** | Histórico de pagamentos | Tabela `payments` para registrar transações |
| **Média** | Emails transacionais | Notificar renovação, falha de pagamento, etc. |
| **Média** | Página de faturamento | Frontend com histórico e faturas |
| **Baixa** | Upgrade mid-cycle | Trocar de plano durante o ciclo |
| **Baixa** | Cupons de desconto | Sistema de cupons promocionais |
## 📋 Credenciais e Configurações
### PayPal Sandbox
```
Client ID: AU-E_dptCQSa_xGUmU--0pTPuZ25AWKOFP6uvamzPQZHrg1nfRaVhEebtpJ1jU_8OKyMocglbesAwIpR
Webhook ID: 4UM53122EW59785
Mode: sandbox
```
### Planos no Banco de Dados
| ID | Slug | Preço | PayPal Plan ID |
|----|------|-------|----------------|
| 1 | free | 0.00 | - |
| 2 | pro-monthly | 9.99 | P-3FJ50989UN7098919M7A752Y |
| 3 | pro-yearly | 99.99 | P-9FN08839NE7915003M7A76JY |
### Assinatura de Teste
```
User: marco@cnxifly.com (ID: 1)
Plan: Pro Mensual (ID: 2)
PayPal ID: I-RHE4CFSL3T3N
Status: active
```
## 🚀 Como Continuar
1. **Para testar limites Free:**
```bash
# Criar usuário de teste
curl -X POST https://webmoney.cnxifly.com/api/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Free","email":"testfree@test.com","password":"Test1234!"}'
```
2. **Para ir para produção:**
- Criar conta PayPal Business
- Obter credenciais Live
- Atualizar .env no servidor
- Criar planos no PayPal Live
- Atualizar IDs no banco
---
*Documento criado para referência futura. Voltar aqui quando retomar o desenvolvimento SaaS.*

View File

@ -25,6 +25,7 @@ import Preferences from './pages/Preferences';
import Profile from './pages/Profile'; import Profile from './pages/Profile';
import Pricing from './pages/Pricing'; import Pricing from './pages/Pricing';
import Billing from './pages/Billing'; import Billing from './pages/Billing';
import Users from './pages/Users';
function App() { function App() {
return ( return (
@ -221,6 +222,16 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/users"
element={
<ProtectedRoute>
<Layout>
<Users />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} /> <Route path="/" element={<Navigate to="/dashboard" />} />
</Routes> </Routes>
<CookieConsent /> <CookieConsent />

View File

@ -13,6 +13,10 @@ const Layout = ({ children }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { date } = useFormatters(); const { date } = useFormatters();
// Admin email - only this user can see business module
const ADMIN_EMAIL = 'marco@cnxifly.com';
const isAdmin = user?.email === ADMIN_EMAIL;
// Mobile: sidebar oculta por padrão | Desktop: expandida // Mobile: sidebar oculta por padrão | Desktop: expandida
const isMobile = () => window.innerWidth < 768; const isMobile = () => window.innerWidth < 768;
const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile: inicia fechada const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile: inicia fechada
@ -47,6 +51,7 @@ const Layout = ({ children }) => {
}; };
const [expandedGroups, setExpandedGroups] = useState({ const [expandedGroups, setExpandedGroups] = useState({
registrations: true,
movements: true, movements: true,
planning: true, planning: true,
settings: false, settings: false,
@ -68,6 +73,16 @@ const Layout = ({ children }) => {
const menuStructure = [ const menuStructure = [
{ type: 'item', path: '/dashboard', icon: 'bi-speedometer2', label: t('nav.dashboard') }, { type: 'item', path: '/dashboard', icon: 'bi-speedometer2', label: t('nav.dashboard') },
{ type: 'item', path: '/accounts', icon: 'bi-wallet2', label: t('nav.accounts') }, { type: 'item', path: '/accounts', icon: 'bi-wallet2', label: t('nav.accounts') },
{
type: 'group',
id: 'registrations',
icon: 'bi-folder',
label: t('nav.registrations'),
items: [
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
]
},
{ {
type: 'group', type: 'group',
id: 'movements', id: 'movements',
@ -81,8 +96,8 @@ const Layout = ({ children }) => {
{ path: '/refunds', icon: 'bi-receipt-cutoff', label: t('nav.refunds') }, { path: '/refunds', icon: 'bi-receipt-cutoff', label: t('nav.refunds') },
] ]
}, },
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') }, // Business module - only visible to admin
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') }, ...(isAdmin ? [{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') }] : []),
{ {
type: 'group', type: 'group',
id: 'planning', id: 'planning',
@ -101,12 +116,12 @@ const Layout = ({ children }) => {
icon: 'bi-gear', icon: 'bi-gear',
label: t('nav.settings'), label: t('nav.settings'),
items: [ items: [
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') }, { path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') }, { path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') }, { path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') }, { path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
// Admin only: User management
...(isAdmin ? [{ path: '/users', icon: 'bi-people', label: t('nav.users') }] : []),
] ]
}, },
]; ];

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import api from '../../services/api';
export default function PlanUsageWidget() {
const { t } = useTranslation();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsage();
}, []);
const loadUsage = async () => {
try {
const response = await api.get('/subscription/status');
if (response.data.success) {
setData(response.data.data);
}
} catch (error) {
console.error('Error loading plan usage:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="card mb-3">
<div className="card-body text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!data || !data.plan) {
return null;
}
const { plan, usage, usage_percentages } = data;
const limits = plan.limits || {};
// Only show for free plan or plans with limits
const hasLimits = Object.values(limits).some(v => v !== null);
if (!hasLimits) {
return null;
}
const resources = [
{ key: 'accounts', icon: 'bi-wallet2', label: t('dashboard.accounts', 'Cuentas') },
{ key: 'categories', icon: 'bi-tags', label: t('dashboard.categories', 'Categorías') },
{ key: 'budgets', icon: 'bi-pie-chart', label: t('dashboard.budgets', 'Presupuestos') },
{ key: 'transactions', icon: 'bi-receipt', label: t('dashboard.transactions', 'Transacciones') },
];
const getProgressColor = (percentage) => {
if (percentage === null) return 'bg-success';
if (percentage >= 100) return 'bg-danger';
if (percentage >= 80) return 'bg-warning';
return 'bg-primary';
};
const hasWarning = Object.values(usage_percentages).some(p => p !== null && p >= 80);
const hasLimit = Object.values(usage_percentages).some(p => p !== null && p >= 100);
return (
<div className={`card mb-3 ${hasLimit ? 'border-danger' : hasWarning ? 'border-warning' : ''}`}>
<div className="card-header d-flex justify-content-between align-items-center py-2">
<span className="fw-semibold">
<i className="bi bi-speedometer2 me-2"></i>
{t('planUsage.title', 'Uso del Plan')} - {plan.name}
</span>
{plan.is_free && (
<Link to="/pricing" className="btn btn-sm btn-primary">
<i className="bi bi-arrow-up-circle me-1"></i>
{t('planUsage.upgrade', 'Mejorar')}
</Link>
)}
</div>
<div className="card-body py-2">
{hasLimit && (
<div className="alert alert-danger py-2 mb-3">
<i className="bi bi-exclamation-triangle-fill me-2"></i>
{t('planUsage.limitReached', 'Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.')}
</div>
)}
<div className="row g-2">
{resources.map(({ key, icon, label }) => {
const current = usage[key] || 0;
const limit = limits[key];
const percentage = usage_percentages[key];
if (limit === null || limit === undefined) {
return null; // Don't show unlimited resources
}
return (
<div key={key} className="col-6 col-md-3">
<div className="text-center">
<div className="d-flex justify-content-between align-items-center mb-1">
<small className="text-muted">
<i className={`bi ${icon} me-1`}></i>
{label}
</small>
<small className={percentage >= 100 ? 'text-danger fw-bold' : percentage >= 80 ? 'text-warning' : ''}>
{current}/{limit}
</small>
</div>
<div className="progress" style={{ height: '6px' }}>
<div
className={`progress-bar ${getProgressColor(percentage)}`}
role="progressbar"
style={{ width: `${Math.min(percentage || 0, 100)}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -86,6 +86,7 @@
"liabilities": "Pasivos", "liabilities": "Pasivos",
"transactions": "Transacciones", "transactions": "Transacciones",
"movements": "Movimientos", "movements": "Movimientos",
"registrations": "Registros",
"import": "Importar", "import": "Importar",
"duplicates": "Duplicados", "duplicates": "Duplicados",
"transfers": "Transferencias", "transfers": "Transferencias",
@ -103,7 +104,8 @@
"goals": "Metas", "goals": "Metas",
"budgets": "Presupuestos", "budgets": "Presupuestos",
"billing": "Facturación", "billing": "Facturación",
"pricing": "Planes" "pricing": "Planes",
"users": "Usuarios"
}, },
"dashboard": { "dashboard": {
"title": "Panel de Control", "title": "Panel de Control",
@ -365,6 +367,8 @@
"liabilities": { "liabilities": {
"title": "Cuentas Pasivo", "title": "Cuentas Pasivo",
"subtitle": "Gestión de préstamos y financiamientos", "subtitle": "Gestión de préstamos y financiamientos",
"manage": "Gestionar Pasivos",
"noLiabilities": "No hay pasivos registrados",
"importContract": "Importar Contrato", "importContract": "Importar Contrato",
"import": "Importar", "import": "Importar",
"importInfo": "Selecciona un archivo Excel (.xlsx) con la tabla de cuotas. El archivo debe contener columnas para: Número de Cuota, Fecha de Vencimiento, Valor de Cuota, Intereses, Capital y Estado.", "importInfo": "Selecciona un archivo Excel (.xlsx) con la tabla de cuotas. El archivo debe contener columnas para: Número de Cuota, Fecha de Vencimiento, Valor de Cuota, Intereses, Capital y Estado.",
@ -2063,6 +2067,16 @@
"a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo." "a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo."
} }
}, },
"planUsage": {
"title": "Uso del Plan",
"upgrade": "Mejorar",
"limitReached": "Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.",
"limitWarning": "Estás cerca del límite de tu plan.",
"accounts": "Cuentas",
"categories": "Categorías",
"budgets": "Presupuestos",
"transactions": "Transacciones"
},
"billing": { "billing": {
"title": "Facturación", "title": "Facturación",
"currentPlan": "Plan Actual", "currentPlan": "Plan Actual",

View File

@ -87,6 +87,7 @@
"liabilities": "Passivos", "liabilities": "Passivos",
"transactions": "Transações", "transactions": "Transações",
"movements": "Movimentações", "movements": "Movimentações",
"registrations": "Cadastros",
"import": "Importar", "import": "Importar",
"duplicates": "Duplicatas", "duplicates": "Duplicatas",
"transfers": "Transferências", "transfers": "Transferências",
@ -104,7 +105,8 @@
"goals": "Metas", "goals": "Metas",
"budgets": "Orçamentos", "budgets": "Orçamentos",
"billing": "Faturamento", "billing": "Faturamento",
"pricing": "Planos" "pricing": "Planos",
"users": "Usuários"
}, },
"dashboard": { "dashboard": {
"title": "Painel de Controle", "title": "Painel de Controle",
@ -367,6 +369,8 @@
"liabilities": { "liabilities": {
"title": "Contas Passivo", "title": "Contas Passivo",
"subtitle": "Gerenciamento de empréstimos e financiamentos", "subtitle": "Gerenciamento de empréstimos e financiamentos",
"manage": "Gerenciar Passivos",
"noLiabilities": "Nenhum passivo cadastrado",
"importContract": "Importar Contrato", "importContract": "Importar Contrato",
"import": "Importar", "import": "Importar",
"importInfo": "Selecione um arquivo Excel (.xlsx) com a tabela de parcelas. O arquivo deve conter colunas para: Número da Parcela, Data de Vencimento, Valor da Cota, Juros, Capital e Estado.", "importInfo": "Selecione um arquivo Excel (.xlsx) com a tabela de parcelas. O arquivo deve conter colunas para: Número da Parcela, Data de Vencimento, Valor da Cota, Juros, Capital e Estado.",
@ -2081,6 +2085,16 @@
"a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo." "a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo."
} }
}, },
"planUsage": {
"title": "Uso do Plano",
"upgrade": "Fazer Upgrade",
"limitReached": "Você atingiu o limite do seu plano. Atualize para Pro para continuar.",
"limitWarning": "Você está perto do limite do seu plano.",
"accounts": "Contas",
"categories": "Categorias",
"budgets": "Orçamentos",
"transactions": "Transações"
},
"billing": { "billing": {
"title": "Faturamento", "title": "Faturamento",
"currentPlan": "Plano Atual", "currentPlan": "Plano Atual",

View File

@ -27,6 +27,7 @@ const Accounts = () => {
const [recalculating, setRecalculating] = useState(false); const [recalculating, setRecalculating] = useState(false);
const [filter, setFilter] = useState({ type: '', is_active: '' }); const [filter, setFilter] = useState({ type: '', is_active: '' });
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts' ou 'liabilities'
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@ -307,7 +308,7 @@ const Accounts = () => {
{!isMobile && <p className="text-slate-400 mb-0">{t('accounts.title')}</p>} {!isMobile && <p className="text-slate-400 mb-0">{t('accounts.title')}</p>}
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{!isMobile && ( {!isMobile && activeTab === 'accounts' && (
<button <button
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
onClick={handleRecalculateBalances} onClick={handleRecalculateBalances}
@ -322,14 +323,56 @@ const Accounts = () => {
{t('accounts.recalculate')} {t('accounts.recalculate')}
</button> </button>
)} )}
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}> {activeTab === 'accounts' && (
<i className="bi bi-plus-lg me-2"></i> <button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}>
{isMobile ? t('common.add') : t('accounts.newAccount')} <i className="bi bi-plus-lg me-2"></i>
</button> {isMobile ? t('common.add') : t('accounts.newAccount')}
</button>
)}
{activeTab === 'liabilities' && (
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
<i className="bi bi-gear me-2"></i>
{isMobile ? t('common.manage') : t('liabilities.manage')}
</button>
)}
</div> </div>
</div> </div>
{/* Summary Cards */} {/* Tabs */}
<ul className="nav nav-tabs mb-4" style={{ borderBottom: '1px solid #334155' }}>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'}`}
onClick={() => setActiveTab('accounts')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent'
}}
>
<i className="bi bi-wallet2 me-2"></i>
{t('nav.accounts')} ({accounts.length})
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'}`}
onClick={() => setActiveTab('liabilities')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent'
}}
>
<i className="bi bi-bank me-2"></i>
{t('nav.liabilities')} ({liabilityAccounts.length})
</button>
</li>
</ul>
{activeTab === 'accounts' && (
<>
{/* Summary Cards */}
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}> <div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
{/* Total por Moeda */} {/* Total por Moeda */}
<div className="col-md-6"> <div className="col-md-6">
@ -611,23 +654,15 @@ const Accounts = () => {
)} )}
</div> </div>
</div> </div>
</>
)}
{/* Liability Accounts Section */} {/* Tab de Passivos */}
{liabilityAccounts.length > 0 && (filter.type === '' || filter.type === 'liability') && ( {activeTab === 'liabilities' && (
<div className="card border-0 mt-4" style={{ background: '#1e293b' }}> <>
<div className={`card-header border-0 d-flex justify-content-between align-items-center ${isMobile ? 'py-2 px-3' : ''}`} style={{ background: '#1e293b', borderBottom: '1px solid #334155' }}> {/* Liability Accounts Section */}
<h5 className={`mb-0 text-white ${isMobile ? 'fs-6' : ''}`}> {liabilityAccounts.length > 0 ? (
<i className="bi bi-file-earmark-text me-2 text-danger"></i> <div className="card border-0" style={{ background: '#1e293b' }}>
{t('liabilities.title')}
</h5>
<button
className="btn btn-sm btn-outline-light"
onClick={() => navigate('/liabilities')}
>
<i className="bi bi-box-arrow-up-right me-1"></i>
{t('common.details')}
</button>
</div>
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}> <div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
{isMobile ? ( {isMobile ? (
// Mobile: Cards Layout // Mobile: Cards Layout
@ -798,6 +833,19 @@ const Accounts = () => {
)} )}
</div> </div>
</div> </div>
) : (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-bank display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">{t('liabilities.noLiabilities')}</p>
<button className="btn btn-primary" onClick={() => navigate('/liabilities')}>
<i className="bi bi-plus-lg me-2"></i>
{t('liabilities.import')}
</button>
</div>
</div>
)}
</>
)} )}
{/* Modal de Criar/Editar */} {/* Modal de Criar/Editar */}

View File

@ -10,6 +10,7 @@ import OverpaymentsAnalysis from '../components/dashboard/OverpaymentsAnalysis';
import CalendarWidget from '../components/dashboard/CalendarWidget'; import CalendarWidget from '../components/dashboard/CalendarWidget';
import UpcomingWidget from '../components/dashboard/UpcomingWidget'; import UpcomingWidget from '../components/dashboard/UpcomingWidget';
import OverdueWidget from '../components/dashboard/OverdueWidget'; import OverdueWidget from '../components/dashboard/OverdueWidget';
import PlanUsageWidget from '../components/dashboard/PlanUsageWidget';
const Dashboard = () => { const Dashboard = () => {
const { user } = useAuth(); const { user } = useAuth();
@ -230,6 +231,9 @@ const Dashboard = () => {
return ( return (
<div className="dashboard-container"> <div className="dashboard-container">
{/* Plan Usage Widget - Show for free plan */}
<PlanUsageWidget />
{/* Header */} {/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4"> <div className="d-flex justify-content-between align-items-center mb-4">
<div> <div>

View File

@ -0,0 +1,769 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import api from '../services/api';
import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal';
function Users() {
const { t } = useTranslation();
const { showToast } = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all'); // all, active, free
const [pagination, setPagination] = useState({
current_page: 1,
last_page: 1,
total: 0,
});
const [summary, setSummary] = useState(null);
const [selectedUser, setSelectedUser] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Create user modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', user_type: 'free' });
const [createdUser, setCreatedUser] = useState(null);
const [creating, setCreating] = useState(false);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
fetchUsers();
fetchSummary();
}, [search, filter, pagination.current_page]);
const fetchSummary = async () => {
try {
const response = await api.get('/admin/users/summary');
if (response.data.success) {
setSummary(response.data.data);
}
} catch (error) {
console.error('Error fetching summary:', error);
}
};
const fetchUsers = async () => {
setLoading(true);
try {
const params = {
page: pagination.current_page,
per_page: 20,
};
if (search) {
params.search = search;
}
if (filter !== 'all') {
params.subscription_status = filter;
}
const response = await api.get('/admin/users', { params });
if (response.data.success) {
setUsers(response.data.data);
setPagination(prev => ({
...prev,
...response.data.pagination,
}));
}
} catch (error) {
showToast('Error al cargar usuarios', 'error');
console.error(error);
} finally {
setLoading(false);
}
};
const handleSearch = (e) => {
e.preventDefault();
setPagination(prev => ({ ...prev, current_page: 1 }));
};
const handleDeleteUser = async () => {
if (!selectedUser) return;
try {
const response = await api.delete(`/admin/users/${selectedUser.id}`);
if (response.data.success) {
showToast('Usuario eliminado correctamente', 'success');
setShowDeleteModal(false);
setSelectedUser(null);
fetchUsers();
fetchSummary();
}
} catch (error) {
showToast(error.response?.data?.message || 'Error al eliminar usuario', 'error');
}
};
const handleResetPassword = async () => {
if (!selectedUser) return;
try {
const response = await api.post(`/admin/users/${selectedUser.id}/reset-password`);
if (response.data.success) {
setNewPassword(response.data.data.temporary_password);
showToast('Contraseña restablecida', 'success');
}
} catch (error) {
showToast('Error al restablecer contraseña', 'error');
}
};
const getStatusBadge = (subscription) => {
if (!subscription) {
return <span className="badge bg-secondary">Free</span>;
}
const statusColors = {
active: 'bg-success',
canceled: 'bg-danger',
suspended: 'bg-warning text-dark',
};
return (
<span className={`badge ${statusColors[subscription.status] || 'bg-secondary'}`}>
{subscription.plan_name}
</span>
);
};
const handleCreateUser = async (e) => {
e.preventDefault();
setCreating(true);
try {
const response = await api.post('/admin/users', createForm);
if (response.data.success) {
setCreatedUser(response.data.data);
showToast('Usuario creado correctamente', 'success');
fetchUsers();
fetchSummary();
}
} catch (error) {
const message = error.response?.data?.message || 'Error al crear usuario';
showToast(message, 'error');
} finally {
setCreating(false);
}
};
const resetCreateModal = () => {
setShowCreateModal(false);
setCreateForm({ name: '', email: '', password: '', user_type: 'free' });
setCreatedUser(null);
};
const getUserTypeLabel = (type) => {
const labels = {
free: 'Free',
pro: 'Pro (sin suscripción)',
admin: 'Administrador',
};
return labels[type] || type;
};
const getUserTypeBadge = (type) => {
const badges = {
free: 'bg-secondary',
pro: 'bg-success',
admin: 'bg-danger',
};
return badges[type] || 'bg-secondary';
};
const formatDate = (date) => {
if (!date) return '-';
return new Date(date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<div>
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 className={`text-white ${isMobile ? 'mb-0 fs-4' : 'mb-1'}`}>
<i className="bi bi-people me-2 text-primary"></i>
Gestión de Usuarios
</h2>
{!isMobile && <p className="text-slate-400 mb-0">Administra todos los usuarios del sistema</p>}
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary"
>
<i className="bi bi-person-plus me-2"></i>
{isMobile ? '' : 'Crear Usuario'}
</button>
</div>
{/* Summary Cards */}
{summary && (
<div className={`row ${isMobile ? 'g-2 mb-3' : 'g-3 mb-4'}`}>
<div className="col-6 col-md-3">
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-body p-3">
<div className="d-flex align-items-center">
<div className="rounded-circle bg-primary bg-opacity-25 p-2 me-3">
<i className="bi bi-people text-primary fs-5"></i>
</div>
<div>
<div className="text-slate-400 small">Total Usuarios</div>
<div className="text-white fs-4 fw-bold">{summary.total_users}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-body p-3">
<div className="d-flex align-items-center">
<div className="rounded-circle bg-success bg-opacity-25 p-2 me-3">
<i className="bi bi-credit-card text-success fs-5"></i>
</div>
<div>
<div className="text-slate-400 small">Suscriptores</div>
<div className="text-white fs-4 fw-bold">{summary.active_subscribers}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-body p-3">
<div className="d-flex align-items-center">
<div className="rounded-circle bg-secondary bg-opacity-25 p-2 me-3">
<i className="bi bi-person text-secondary fs-5"></i>
</div>
<div>
<div className="text-slate-400 small">Free</div>
<div className="text-white fs-4 fw-bold">{summary.free_users}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
<div className="card-body p-3">
<div className="d-flex align-items-center">
<div className="rounded-circle bg-info bg-opacity-25 p-2 me-3">
<i className="bi bi-person-plus text-info fs-5"></i>
</div>
<div>
<div className="text-slate-400 small">Nuevos este mes</div>
<div className="text-white fs-4 fw-bold">{summary.new_users_this_month}</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Search and Filter */}
<div className="card border-0 mb-4" style={{ background: '#1e293b' }}>
<div className="card-body">
<div className="row g-3">
<div className="col-md-6">
<form onSubmit={handleSearch}>
<div className="input-group">
<span className="input-group-text bg-dark border-secondary text-slate-400">
<i className="bi bi-search"></i>
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por nombre o email..."
className="form-control bg-dark border-secondary text-white"
/>
</div>
</form>
</div>
<div className="col-md-6">
<div className="btn-group w-100">
<button
onClick={() => setFilter('all')}
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
>
Todos
</button>
<button
onClick={() => setFilter('active')}
className={`btn ${filter === 'active' ? 'btn-success' : 'btn-outline-secondary'}`}
>
Suscriptores
</button>
<button
onClick={() => setFilter('free')}
className={`btn ${filter === 'free' ? 'btn-secondary' : 'btn-outline-secondary'}`}
>
Free
</button>
</div>
</div>
</div>
</div>
</div>
{/* Users Table */}
<div className="card border-0" style={{ background: '#1e293b' }}>
{loading ? (
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : users.length === 0 ? (
<div className="card-body text-center py-5">
<i className="bi bi-people fs-1 text-slate-500 mb-3 d-block"></i>
<p className="text-slate-400">No se encontraron usuarios</p>
</div>
) : (
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr className="text-slate-400 small">
<th className="border-0 py-3">Usuario</th>
<th className="border-0 py-3">Plan</th>
<th className="border-0 py-3 d-none d-md-table-cell">Uso</th>
<th className="border-0 py-3 d-none d-md-table-cell">Registro</th>
<th className="border-0 py-3 text-end">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td className="align-middle">
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: '40px',
height: '40px',
background: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
}}
>
<span className="text-white fw-bold">
{user.name?.charAt(0)?.toUpperCase() || 'U'}
</span>
</div>
<div>
<div className="text-white fw-medium">{user.name}</div>
<div className="text-slate-400 small">{user.email}</div>
</div>
</div>
</td>
<td className="align-middle">
{getStatusBadge(user.subscription)}
</td>
<td className="align-middle d-none d-md-table-cell">
<div className="text-slate-400 small">
<span className="me-3" title="Cuentas">
<i className="bi bi-wallet2 me-1"></i>
{user.usage?.accounts || 0}
</span>
<span title="Transacciones">
<i className="bi bi-arrow-left-right me-1"></i>
{user.usage?.transactions || 0}
</span>
</div>
</td>
<td className="align-middle d-none d-md-table-cell text-slate-400">
<i className="bi bi-calendar3 me-1"></i>
{formatDate(user.created_at)}
</td>
<td className="align-middle text-end">
<button
onClick={() => {
setSelectedUser(user);
setNewPassword(null);
setShowResetPasswordModal(true);
}}
className="btn btn-sm btn-outline-primary me-1"
title="Restablecer contraseña"
>
<i className="bi bi-key"></i>
</button>
<button
onClick={() => {
setSelectedUser(user);
setShowDeleteModal(true);
}}
disabled={user.email === 'marco@cnxifly.com'}
className="btn btn-sm btn-outline-danger"
title="Eliminar usuario"
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{pagination.last_page > 1 && (
<div className="card-footer bg-transparent border-secondary d-flex justify-content-between align-items-center">
<div className="text-slate-400 small">
Página {pagination.current_page} de {pagination.last_page} ({pagination.total} usuarios)
</div>
<div className="btn-group">
<button
onClick={() => setPagination(prev => ({ ...prev, current_page: prev.current_page - 1 }))}
disabled={pagination.current_page === 1}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-chevron-left"></i>
</button>
<button
onClick={() => setPagination(prev => ({ ...prev, current_page: prev.current_page + 1 }))}
disabled={pagination.current_page === pagination.last_page}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-chevron-right"></i>
</button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
<ConfirmModal
show={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDeleteUser}
title="Eliminar Usuario"
message={`¿Estás seguro de que deseas eliminar a ${selectedUser?.name}? Esta acción no se puede deshacer y eliminará todos sus datos.`}
confirmText="Eliminar"
confirmVariant="danger"
/>
{/* Reset Password Modal */}
{showResetPasswordModal && selectedUser && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content bg-dark text-white border-secondary">
<div className="modal-header border-secondary">
<h5 className="modal-title">
<i className="bi bi-key me-2 text-primary"></i>
Restablecer Contraseña
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => setShowResetPasswordModal(false)}
></button>
</div>
<div className="modal-body">
{newPassword ? (
<div className="text-center">
<p className="text-slate-400">
Nueva contraseña para <strong className="text-white">{selectedUser.name}</strong>:
</p>
<div className="bg-secondary bg-opacity-25 rounded p-3 mb-3">
<code className="text-primary fs-5">{newPassword}</code>
</div>
<p className="text-slate-400 small">
<i className="bi bi-exclamation-triangle me-1 text-warning"></i>
Copia esta contraseña y envíala al usuario de forma segura.
</p>
</div>
) : (
<p className="text-slate-400">
Se generará una contraseña temporal para <strong className="text-white">{selectedUser.name}</strong>.
</p>
)}
</div>
<div className="modal-footer border-secondary">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setShowResetPasswordModal(false);
setNewPassword(null);
}}
>
{newPassword ? 'Cerrar' : 'Cancelar'}
</button>
{!newPassword && (
<button
type="button"
className="btn btn-primary"
onClick={handleResetPassword}
>
<i className="bi bi-key me-2"></i>
Generar Contraseña
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* Create User Modal */}
{showCreateModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content bg-dark text-white border-secondary">
<div className="modal-header border-secondary">
<h5 className="modal-title">
<i className="bi bi-person-plus me-2 text-primary"></i>
Crear Nuevo Usuario
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={resetCreateModal}
></button>
</div>
{createdUser ? (
<>
<div className="modal-body">
<div className="text-center">
<div className="mb-4">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
style={{
width: '60px',
height: '60px',
background: createdUser.user_type === 'admin'
? 'linear-gradient(135deg, #dc3545 0%, #b02a37 100%)'
: createdUser.user_type === 'pro'
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
: 'linear-gradient(135deg, #6c757d 0%, #5a6268 100%)'
}}
>
<i className={`bi ${createdUser.user_type === 'admin' ? 'bi-shield-check' : createdUser.user_type === 'pro' ? 'bi-star-fill' : 'bi-person'} text-white fs-2`}></i>
</div>
<h5 className="text-white mb-1">¡Usuario Creado!</h5>
<p className="text-slate-400 mb-0">{createdUser.user.name}</p>
<p className="text-slate-400 small">{createdUser.user.email}</p>
<span className={`badge ${getUserTypeBadge(createdUser.user_type)} mt-2`}>
{getUserTypeLabel(createdUser.user_type)}
</span>
</div>
{createdUser.temporary_password && (
<div className="bg-secondary bg-opacity-25 rounded p-3 mb-3">
<p className="text-slate-400 small mb-2">Contraseña temporal:</p>
<code className="text-primary fs-5">{createdUser.temporary_password}</code>
</div>
)}
{createdUser.subscription && (
<div className="alert alert-success bg-success bg-opacity-10 border-success text-success small mb-3">
<i className="bi bi-check-circle me-2"></i>
<strong>Plan {createdUser.subscription.plan}</strong> activado. {createdUser.subscription.expires}
</div>
)}
<div className={`alert ${createdUser.user_type === 'admin' ? 'alert-danger bg-danger' : createdUser.user_type === 'pro' ? 'alert-success bg-success' : 'alert-info bg-info'} bg-opacity-10 border-${createdUser.user_type === 'admin' ? 'danger' : createdUser.user_type === 'pro' ? 'success' : 'info'} text-${createdUser.user_type === 'admin' ? 'danger' : createdUser.user_type === 'pro' ? 'success' : 'info'} small`}>
<i className="bi bi-info-circle me-2"></i>
{createdUser.user_type === 'admin' && 'Este usuario tiene permisos de administrador con acceso completo al sistema.'}
{createdUser.user_type === 'pro' && 'Este usuario tiene acceso Pro completo sin necesidad de pagar suscripción.'}
{createdUser.user_type === 'free' && 'Este usuario tiene acceso Free con los límites del plan gratuito.'}
</div>
{createdUser.temporary_password && (
<p className="text-slate-400 small">
<i className="bi bi-exclamation-triangle me-1 text-warning"></i>
Copia esta contraseña y envíala al usuario de forma segura.
</p>
)}
</div>
</div>
<div className="modal-footer border-secondary">
<button
type="button"
className="btn btn-primary"
onClick={resetCreateModal}
>
Cerrar
</button>
</div>
</>
) : (
<form onSubmit={handleCreateUser}>
<div className="modal-body">
<div className="mb-3">
<label className="form-label text-slate-400">Nombre *</label>
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={createForm.name}
onChange={(e) => setCreateForm(prev => ({ ...prev, name: e.target.value }))}
required
placeholder="Nombre completo"
/>
</div>
<div className="mb-3">
<label className="form-label text-slate-400">Email *</label>
<input
type="email"
className="form-control bg-dark border-secondary text-white"
value={createForm.email}
onChange={(e) => setCreateForm(prev => ({ ...prev, email: e.target.value }))}
required
placeholder="email@ejemplo.com"
/>
</div>
<div className="mb-3">
<label className="form-label text-slate-400">
Contraseña
<span className="text-slate-500 ms-2">(opcional)</span>
</label>
<input
type="password"
className="form-control bg-dark border-secondary text-white"
value={createForm.password}
onChange={(e) => setCreateForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="Dejar vacío para generar automáticamente"
minLength={8}
/>
<small className="text-slate-500">
Si no se proporciona, se generará una contraseña temporal automáticamente.
</small>
</div>
<div className="mb-3">
<label className="form-label text-slate-400">Tipo de Usuario *</label>
<div className="d-flex flex-column gap-2">
<div
className={`p-3 rounded border cursor-pointer ${createForm.user_type === 'free' ? 'border-secondary bg-secondary bg-opacity-25' : 'border-secondary border-opacity-25'}`}
style={{ cursor: 'pointer' }}
onClick={() => setCreateForm(prev => ({ ...prev, user_type: 'free' }))}
>
<div className="form-check mb-0">
<input
className="form-check-input"
type="radio"
name="user_type"
checked={createForm.user_type === 'free'}
onChange={() => setCreateForm(prev => ({ ...prev, user_type: 'free' }))}
/>
<label className="form-check-label w-100">
<div className="d-flex align-items-center">
<i className="bi bi-person text-secondary me-2 fs-5"></i>
<div>
<div className="text-white fw-medium">Free</div>
<small className="text-slate-500">Sin suscripción, límites del plan gratuito</small>
</div>
</div>
</label>
</div>
</div>
<div
className={`p-3 rounded border cursor-pointer ${createForm.user_type === 'pro' ? 'border-success bg-success bg-opacity-25' : 'border-secondary border-opacity-25'}`}
style={{ cursor: 'pointer' }}
onClick={() => setCreateForm(prev => ({ ...prev, user_type: 'pro' }))}
>
<div className="form-check mb-0">
<input
className="form-check-input"
type="radio"
name="user_type"
checked={createForm.user_type === 'pro'}
onChange={() => setCreateForm(prev => ({ ...prev, user_type: 'pro' }))}
/>
<label className="form-check-label w-100">
<div className="d-flex align-items-center">
<i className="bi bi-star-fill text-success me-2 fs-5"></i>
<div>
<div className="text-white fw-medium">Pro <span className="badge bg-success ms-1">Sin pagar</span></div>
<small className="text-slate-500">Acceso Pro completo sin necesidad de suscripción</small>
</div>
</div>
</label>
</div>
</div>
<div
className={`p-3 rounded border cursor-pointer ${createForm.user_type === 'admin' ? 'border-danger bg-danger bg-opacity-25' : 'border-secondary border-opacity-25'}`}
style={{ cursor: 'pointer' }}
onClick={() => setCreateForm(prev => ({ ...prev, user_type: 'admin' }))}
>
<div className="form-check mb-0">
<input
className="form-check-input"
type="radio"
name="user_type"
checked={createForm.user_type === 'admin'}
onChange={() => setCreateForm(prev => ({ ...prev, user_type: 'admin' }))}
/>
<label className="form-check-label w-100">
<div className="d-flex align-items-center">
<i className="bi bi-shield-check text-danger me-2 fs-5"></i>
<div>
<div className="text-white fw-medium">Administrador <span className="badge bg-danger ms-1">Admin</span></div>
<small className="text-slate-500">Acceso Pro + permisos de administración del sistema</small>
</div>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
<div className="modal-footer border-secondary">
<button
type="button"
className="btn btn-secondary"
onClick={resetCreateModal}
disabled={creating}
>
Cancelar
</button>
<button
type="submit"
className="btn btn-primary"
disabled={creating || !createForm.name || !createForm.email}
>
{creating ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Creando...
</>
) : (
<>
<i className="bi bi-person-plus me-2"></i>
Crear Usuario
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
</div>
)}
</div>
);
}
export default Users;