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:
parent
679a1bc4b2
commit
3a336eb692
34
CHANGELOG.md
34
CHANGELOG.md
@ -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
|
||||||
|
|||||||
185
backend/app/Console/Commands/SetupPayPalPlans.php
Normal file
185
backend/app/Console/Commands/SetupPayPalPlans.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
293
backend/app/Http/Controllers/Api/UserManagementController.php
Normal file
293
backend/app/Http/Controllers/Api/UserManagementController.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/app/Http/Middleware/AdminOnly.php
Normal file
32
backend/app/Http/Middleware/AdminOnly.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
backend/app/Http/Middleware/CheckPlanLimits.php
Normal file
106
backend/app/Http/Middleware/CheckPlanLimits.php
Normal 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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
129
docs/SAAS_STATUS.md
Normal 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.*
|
||||||
@ -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 />
|
||||||
|
|||||||
@ -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') }] : []),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
129
frontend/src/components/dashboard/PlanUsageWidget.jsx
Normal file
129
frontend/src/components/dashboard/PlanUsageWidget.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
769
frontend/src/pages/Users.jsx
Normal file
769
frontend/src/pages/Users.jsx
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user