feat: add VCard API integration with configuration and proxy endpoints
- Implemented VCard API controller with methods to manage configuration (save, get). - Added proxy functions to handle API requests for users, vcards, memberships, payments, transactions, logs, and miniwebs. - Included error handling and response formatting for API interactions.
This commit is contained in:
@@ -0,0 +1,315 @@
|
|||||||
|
# API Admin — Guia de Implementacion
|
||||||
|
|
||||||
|
> Stack: Laravel + MongoDB + Laravel Sanctum
|
||||||
|
> Prefijo base: /api/admin/*
|
||||||
|
> Seguridad: Bearer Token (Sanctum) + rol administrador
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Archivos creados / modificados
|
||||||
|
|
||||||
|
| Archivo | Descripcion |
|
||||||
|
|---------|-------------|
|
||||||
|
| app/Http/Middleware/AdminApiMiddleware.php | Verifica rol=administrador. Retorna JSON 403 (sin redirect). |
|
||||||
|
| app/Http/Kernel.php | Registra alias admin.api |
|
||||||
|
| app/Http/Controllers/Api/Admin/UsuarioController.php | CRUD usuarios + activar/desactivar |
|
||||||
|
| app/Http/Controllers/Api/Admin/VcardController.php | Listar, ver, editar vcards; por usuario |
|
||||||
|
| app/Http/Controllers/Api/Admin/MembresiaController.php | Ver/activar/desactivar membresia + cambiar plan |
|
||||||
|
| app/Http/Controllers/Api/Admin/PagoController.php | Historial HealthPagos + transacciones wallet |
|
||||||
|
| app/Http/Controllers/Api/Admin/LogController.php | Log de movimientos global y por usuario |
|
||||||
|
| app/Http/Controllers/Api/Admin/MiniWebController.php | Miniwebs con usuario y vcard vinculada |
|
||||||
|
| routes/api.php | 34 rutas nuevas bajo /api/admin/* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Implementar en otro proyecto
|
||||||
|
|
||||||
|
### 2.1 Requisitos previos
|
||||||
|
- Laravel >= 10
|
||||||
|
- mongodb/laravel-mongodb instalado
|
||||||
|
- laravel/sanctum configurado
|
||||||
|
- Modelo User con HasApiTokens y relacion rol() -> Rol
|
||||||
|
- Modelo Rol con campo nombre (valor "administrador")
|
||||||
|
|
||||||
|
### 2.2 Copiar estos archivos
|
||||||
|
```
|
||||||
|
app/Http/Middleware/AdminApiMiddleware.php
|
||||||
|
app/Http/Controllers/Api/Admin/UsuarioController.php
|
||||||
|
app/Http/Controllers/Api/Admin/VcardController.php
|
||||||
|
app/Http/Controllers/Api/Admin/MembresiaController.php
|
||||||
|
app/Http/Controllers/Api/Admin/PagoController.php
|
||||||
|
app/Http/Controllers/Api/Admin/LogController.php
|
||||||
|
app/Http/Controllers/Api/Admin/MiniWebController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Registrar middleware en app/Http/Kernel.php
|
||||||
|
```php
|
||||||
|
protected $routeMiddleware = [
|
||||||
|
// ... existentes ...
|
||||||
|
'admin.api' => \App\Http\Middleware\AdminApiMiddleware::class,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Agregar rutas en routes/api.php
|
||||||
|
```php
|
||||||
|
use App\Http\Controllers\Api\Admin\UsuarioController as AdminUsuario;
|
||||||
|
use App\Http\Controllers\Api\Admin\VcardController as AdminVcard;
|
||||||
|
use App\Http\Controllers\Api\Admin\MembresiaController as AdminMembresia;
|
||||||
|
use App\Http\Controllers\Api\Admin\PagoController as AdminPago;
|
||||||
|
use App\Http\Controllers\Api\Admin\LogController as AdminLog;
|
||||||
|
use App\Http\Controllers\Api\Admin\MiniWebController as AdminMiniWeb;
|
||||||
|
|
||||||
|
Route::middleware(['auth:sanctum', 'admin.api'])->prefix('admin')->name('admin.')->group(function () {
|
||||||
|
|
||||||
|
// Usuarios
|
||||||
|
Route::get('usuarios', [AdminUsuario::class, 'index']);
|
||||||
|
Route::get('usuarios/{id}', [AdminUsuario::class, 'show']);
|
||||||
|
Route::put('usuarios/{id}', [AdminUsuario::class, 'update']);
|
||||||
|
Route::post('usuarios/{id}/activar', [AdminUsuario::class, 'activar']);
|
||||||
|
Route::post('usuarios/{id}/desactivar', [AdminUsuario::class, 'desactivar']);
|
||||||
|
|
||||||
|
// VCards
|
||||||
|
Route::get('vcards', [AdminVcard::class, 'index']);
|
||||||
|
Route::get('vcards/{id}', [AdminVcard::class, 'show']);
|
||||||
|
Route::put('vcards/{id}', [AdminVcard::class, 'update']);
|
||||||
|
Route::get('usuarios/{userId}/vcards', [AdminVcard::class, 'byUsuario']);
|
||||||
|
|
||||||
|
// Membresias
|
||||||
|
Route::get('planes', [AdminMembresia::class, 'planes']);
|
||||||
|
Route::get('usuarios/{id}/membresia', [AdminMembresia::class, 'show']);
|
||||||
|
Route::post('usuarios/{id}/activar-membresia', [AdminMembresia::class, 'activar']);
|
||||||
|
Route::post('usuarios/{id}/desactivar-membresia', [AdminMembresia::class, 'desactivar']);
|
||||||
|
Route::post('usuarios/{id}/cambiar-plan', [AdminMembresia::class, 'cambiarPlan']);
|
||||||
|
|
||||||
|
// Pagos
|
||||||
|
Route::get('pagos', [AdminPago::class, 'index']);
|
||||||
|
Route::get('transacciones', [AdminPago::class, 'transacciones']);
|
||||||
|
Route::get('usuarios/{userId}/pagos', [AdminPago::class, 'byUsuario']);
|
||||||
|
Route::get('usuarios/{userId}/transacciones', [AdminPago::class, 'transaccionesByUsuario']);
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
Route::get('logs', [AdminLog::class, 'index']);
|
||||||
|
Route::get('usuarios/{userId}/logs', [AdminLog::class, 'byUsuario']);
|
||||||
|
|
||||||
|
// MiniWebs
|
||||||
|
Route::get('miniwebs', [AdminMiniWeb::class, 'index']);
|
||||||
|
Route::get('miniwebs/{id}', [AdminMiniWeb::class, 'show']);
|
||||||
|
Route::get('usuarios/{userId}/miniwebs', [AdminMiniWeb::class, 'byUsuario']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flujo de autenticacion
|
||||||
|
|
||||||
|
### Login
|
||||||
|
```http
|
||||||
|
POST /api/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Respuesta:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "1|abc123...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"user": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usar token en cada peticion
|
||||||
|
```
|
||||||
|
Authorization: Bearer 1|abc123...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
```http
|
||||||
|
POST /api/logout
|
||||||
|
Authorization: Bearer 1|abc123...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Referencia de endpoints
|
||||||
|
|
||||||
|
### Usuarios /api/admin/usuarios
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Query params |
|
||||||
|
|--------|------|-------------|--------------|
|
||||||
|
| GET | /api/admin/usuarios | Lista paginada | search, estado, plan_id, per_page |
|
||||||
|
| GET | /api/admin/usuarios/{id} | Detalle: plan, vcards, wallet, perfil | — |
|
||||||
|
| PUT | /api/admin/usuarios/{id} | Edita name, email, rol_id, plan_id | — |
|
||||||
|
| POST | /api/admin/usuarios/{id}/activar | Activa cuenta (estado=true) | — |
|
||||||
|
| POST | /api/admin/usuarios/{id}/desactivar | Desactiva cuenta (estado=false) | — |
|
||||||
|
|
||||||
|
Ejemplo PUT:
|
||||||
|
```json
|
||||||
|
{ "name": "Juan Garcia", "email": "juan@empresa.com", "plan_id": "664abc123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### VCards /api/admin/vcards
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Query params |
|
||||||
|
|--------|------|-------------|--------------|
|
||||||
|
| GET | /api/admin/vcards | Lista paginada | search, estado, user_id, per_page |
|
||||||
|
| GET | /api/admin/vcards/{id} | Detalle con usuario | — |
|
||||||
|
| PUT | /api/admin/vcards/{id} | Edita datos basicos | — |
|
||||||
|
| GET | /api/admin/usuarios/{userId}/vcards | Vcards de un usuario | — |
|
||||||
|
|
||||||
|
Campos editables: nombre, cargo, descripcion, telefono, whatsapp, email, empresa,
|
||||||
|
direccion, website, pais, ciudad, estado, privacidad, es_borrador
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Membresias
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Body |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | /api/admin/planes | Lista planes activos | — |
|
||||||
|
| GET | /api/admin/usuarios/{id}/membresia | Estado de membresia | — |
|
||||||
|
| POST | /api/admin/usuarios/{id}/activar-membresia | Activa/renueva | { "anual": true, "dias": 365 } |
|
||||||
|
| POST | /api/admin/usuarios/{id}/cambiar-plan | Cambia plan | { "plan_id": "abc", "anual": false } |
|
||||||
|
| POST | /api/admin/usuarios/{id}/desactivar-membresia | Desactiva | — |
|
||||||
|
|
||||||
|
Logica activar-membresia:
|
||||||
|
- Si ya tiene membresia vigente, los dias se SUMAN (no se pisa la fecha).
|
||||||
|
- anual:true = +365 dias. anual:false = +30 dias (o los dias que se indiquen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pagos
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Query params |
|
||||||
|
|--------|------|-------------|--------------|
|
||||||
|
| GET | /api/admin/pagos | Historial global HealthPagos | user_id, estado, moneda, desde, hasta, per_page |
|
||||||
|
| GET | /api/admin/transacciones | Transacciones wallet global | type, status, per_page |
|
||||||
|
| GET | /api/admin/usuarios/{id}/pagos | Pagos de un usuario + total pagado | — |
|
||||||
|
| GET | /api/admin/usuarios/{id}/transacciones | Wallet de un usuario | — |
|
||||||
|
|
||||||
|
Valores estado (HealthPago): pendiente, completado, fallido
|
||||||
|
Valores type (Transaction): deposit, withdraw, transfer_in, transfer_out, purchase, adjustment
|
||||||
|
Valores status (Transaction): pending, completed, failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Query params |
|
||||||
|
|--------|------|-------------|--------------|
|
||||||
|
| GET | /api/admin/logs | Log global de movimientos | user_id, type, search, desde, hasta, per_page |
|
||||||
|
| GET | /api/admin/usuarios/{id}/logs | Logs de un usuario | — |
|
||||||
|
|
||||||
|
Valores type: create, edit, delete, delete cuenta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MiniWebs
|
||||||
|
|
||||||
|
| Metodo | Ruta | Descripcion | Query params |
|
||||||
|
|--------|------|-------------|--------------|
|
||||||
|
| GET | /api/admin/miniwebs | Lista paginada | search, user_id, per_page |
|
||||||
|
| GET | /api/admin/miniwebs/{id} | Detalle con usuario + vcard vinculada | — |
|
||||||
|
| GET | /api/admin/usuarios/{id}/miniwebs | Miniwebs de un usuario | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Respuestas de error
|
||||||
|
|
||||||
|
| HTTP | Cuando |
|
||||||
|
|------|--------|
|
||||||
|
| 401 | Token ausente o invalido |
|
||||||
|
| 403 | Autenticado pero sin rol administrador |
|
||||||
|
| 404 | Recurso no encontrado |
|
||||||
|
| 422 | Validacion fallida |
|
||||||
|
|
||||||
|
Ejemplo 403:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Acceso denegado. Se requieren privilegios de administrador.",
|
||||||
|
"code": 403
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Modelos requeridos
|
||||||
|
|
||||||
|
| Modelo | Coleccion MongoDB | Relaciones usadas |
|
||||||
|
|--------|------------------|-------------------|
|
||||||
|
| User | users | rol, plan, perfil, wallet, vcards, setup |
|
||||||
|
| Vcard | vcard | user |
|
||||||
|
| Planes | planes | tarifas |
|
||||||
|
| HealthPago | health_pagos | — |
|
||||||
|
| Transaction | transactions | wallet |
|
||||||
|
| Wallet | wallets | user |
|
||||||
|
| Log | log | user |
|
||||||
|
| MiniWeb | miniwebs | user, vcard |
|
||||||
|
| Rol | rol | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Paginacion
|
||||||
|
|
||||||
|
Todos los listados retornan estructura estandar de Laravel:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_page": 1,
|
||||||
|
"data": [ ... ],
|
||||||
|
"per_page": 20,
|
||||||
|
"total": 150,
|
||||||
|
"last_page": 8,
|
||||||
|
"next_page_url": "https://...",
|
||||||
|
"prev_page_url": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Maximo per_page: 100
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prueba rapida con curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Login y guardar token
|
||||||
|
TOKEN=$(curl -s -X POST https://tudominio.com/api/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@example.com","password":"secret"}' \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||||
|
|
||||||
|
# 2. Listar usuarios
|
||||||
|
curl https://tudominio.com/api/admin/usuarios \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 3. Vcards de un usuario
|
||||||
|
curl https://tudominio.com/api/admin/usuarios/USER_ID/vcards \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 4. Activar membresia anual
|
||||||
|
curl -X POST https://tudominio.com/api/admin/usuarios/USER_ID/activar-membresia \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"anual": true}'
|
||||||
|
|
||||||
|
# 5. Cambiar plan
|
||||||
|
curl -X POST https://tudominio.com/api/admin/usuarios/USER_ID/cambiar-plan \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"plan_id": "PLAN_ID"}'
|
||||||
|
|
||||||
|
# 6. Historial de pagos con filtros
|
||||||
|
curl "https://tudominio.com/api/admin/pagos?estado=completado&desde=2026-01-01&per_page=50" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 7. Logs de un usuario
|
||||||
|
curl https://tudominio.com/api/admin/usuarios/USER_ID/logs \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 8. MiniWebs con busqueda
|
||||||
|
curl "https://tudominio.com/api/admin/miniwebs?search=empresa" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
@@ -90,6 +90,8 @@ func Migrate() {
|
|||||||
// Submódulo Partner
|
// Submódulo Partner
|
||||||
&models.PartnerRecurso{},
|
&models.PartnerRecurso{},
|
||||||
&models.PartnerComunicado{},
|
&models.PartnerComunicado{},
|
||||||
|
// Integración VCard API Admin (Laravel)
|
||||||
|
&models.VcardApiConfig{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
log.Fatalf("Error during main migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VcardApiConfig almacena las credenciales para conectar con la API Admin del
|
||||||
|
// sistema VCard externo (Laravel + Sanctum, prefijo /api/admin/*).
|
||||||
|
type VcardApiConfig struct {
|
||||||
|
gorm.Model
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||||
|
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"`
|
||||||
|
BearerToken string `json:"bearer_token" gorm:"column:bearer_token;type:text;not null"`
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (VcardApiConfig) TableName() string { return "vcard_api_configs" }
|
||||||
|
|
||||||
|
func GetVcardApiConfig() (*VcardApiConfig, error) {
|
||||||
|
var cfg VcardApiConfig
|
||||||
|
if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpsertVcardApiConfig(nombre, baseURL, bearerToken string, activo bool) (*VcardApiConfig, error) {
|
||||||
|
var cfg VcardApiConfig
|
||||||
|
err := app.Http.Database.DB.First(&cfg).Error
|
||||||
|
if err != nil {
|
||||||
|
cfg = VcardApiConfig{
|
||||||
|
Nombre: nombre,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
BearerToken: bearerToken,
|
||||||
|
Activo: activo,
|
||||||
|
}
|
||||||
|
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
|
||||||
|
return nil, createErr
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
cfg.Nombre = nombre
|
||||||
|
cfg.BaseURL = baseURL
|
||||||
|
if bearerToken != "" {
|
||||||
|
cfg.BearerToken = bearerToken
|
||||||
|
}
|
||||||
|
cfg.Activo = activo
|
||||||
|
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
<!-- Vista: VCard API — Integración Admin Laravel -->
|
||||||
|
<div x-data="vcardApiApp()" x-init="init()" class="bg-white rounded-lg shadow">
|
||||||
|
|
||||||
|
<!-- Overlay de carga -->
|
||||||
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">VCard API</h1>
|
||||||
|
<p class="text-xs text-slate-500 mt-0.5">Integración con el sistema VCard externo (Laravel + Sanctum). Gestiona usuarios, vcards, membresías y más.</p>
|
||||||
|
</div>
|
||||||
|
<button @click="configModal = true"
|
||||||
|
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
||||||
|
style="background-color:#8eb02f"
|
||||||
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||||
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
||||||
|
</svg>
|
||||||
|
Configurar API
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerta sin configuración -->
|
||||||
|
<div x-show="!hasConfig" class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
|
||||||
|
<strong>Sin configuración activa.</strong> Haz clic en "Configurar API" para ingresar la URL base y el Bearer Token del sistema VCard.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge config activa -->
|
||||||
|
<div x-show="hasConfig && configData" class="mb-5 flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg text-sm">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-green-500 inline-block"></span>
|
||||||
|
<span class="text-green-800 font-medium" x-text="configData ? configData.nombre || configData.base_url : ''"></span>
|
||||||
|
<span class="text-green-600 text-xs" x-text="configData ? configData.base_url : ''"></span>
|
||||||
|
<span x-show="configData && !configData.activo" class="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600">Inactiva</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-6">
|
||||||
|
<nav class="-mb-px flex flex-wrap gap-4 text-sm">
|
||||||
|
<button @click="setTab('usuarios')" :class="tab==='usuarios' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Usuarios</button>
|
||||||
|
<button @click="setTab('vcards')" :class="tab==='vcards' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">VCards</button>
|
||||||
|
<button @click="setTab('planes')" :class="tab==='planes' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Planes</button>
|
||||||
|
<button @click="setTab('pagos')" :class="tab==='pagos' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Pagos</button>
|
||||||
|
<button @click="setTab('transacciones')" :class="tab==='transacciones' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Transacciones</button>
|
||||||
|
<button @click="setTab('logs')" :class="tab==='logs' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">Logs</button>
|
||||||
|
<button @click="setTab('miniwebs')" :class="tab==='miniwebs' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'" class="pb-2 transition-colors">MiniWebs</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barra de búsqueda + cargar -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
|
<input x-model="search" @keyup.enter="loadTab()" type="text" placeholder="Buscar..." class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
|
<button @click="loadTab()" :disabled="loading || !hasConfig"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-40">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="loading ? 'Cargando...' : 'Cargar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerta de error -->
|
||||||
|
<div x-show="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Usuarios ─── -->
|
||||||
|
<div x-show="tab === 'usuarios'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Email</th>
|
||||||
|
<th class="py-2 px-3">Plan</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="u in items" :key="u.id || u._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="u.name || u.nombre || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="u.email || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-xs" x-text="(u.plan && u.plan.nombre) ? u.plan.nombre : '—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="u.estado ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full" x-text="u.estado ? 'Activo' : 'Inactivo'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button x-show="!u.estado" @click="toggleUsuario(u, 'activar')" title="Activar"
|
||||||
|
class="text-green-500 hover:text-green-700 text-xs px-2 py-1 border border-green-300 rounded">Activar</button>
|
||||||
|
<button x-show="u.estado" @click="toggleUsuario(u, 'desactivar')" title="Desactivar"
|
||||||
|
class="text-red-400 hover:text-red-600 text-xs px-2 py-1 border border-red-200 rounded">Desactivar</button>
|
||||||
|
<button @click="openMembresiaModal(u)" title="Membresía"
|
||||||
|
class="text-blue-500 hover:text-blue-700 text-xs px-2 py-1 border border-blue-200 rounded">Membresía</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: VCards ─── -->
|
||||||
|
<div x-show="tab === 'vcards'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Nombre</th>
|
||||||
|
<th class="py-2 px-3">Empresa</th>
|
||||||
|
<th class="py-2 px-3">Email</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3">Privacidad</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="v in items" :key="v.id || v._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="v.nombre || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="v.empresa || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="v.email || '—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="v.estado ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full" x-text="v.estado ? 'Activa' : 'Inactiva'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="v.privacidad === 'publica' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full" x-text="v.privacidad || '—'"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Planes ─── -->
|
||||||
|
<div x-show="tab === 'planes'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="items.length > 0">
|
||||||
|
<template x-for="p in items" :key="p.id || p._id">
|
||||||
|
<div class="border rounded-xl p-4 bg-white shadow-sm hover:shadow-md transition">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold text-sm" x-text="p.nombre || '—'"></span>
|
||||||
|
<span :class="p.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full" x-text="p.activo ? 'Activo' : 'Inactivo'"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-2" x-text="p.descripcion || ''"></p>
|
||||||
|
<div class="text-xs text-gray-600 space-y-1">
|
||||||
|
<div x-show="p.tarifas && p.tarifas.length">
|
||||||
|
<template x-for="t in (p.tarifas || [])" :key="t.id">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span x-text="t.periodo || '—'"></span>
|
||||||
|
<span class="font-mono" x-text="(t.moneda || '') + ' ' + (t.precio || '—')"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Pagos ─── -->
|
||||||
|
<div x-show="tab === 'pagos'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Referencia</th>
|
||||||
|
<th class="py-2 px-3">Usuario</th>
|
||||||
|
<th class="py-2 px-3">Monto</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3">Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="p in items" :key="p.id || p._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-mono text-xs" x-text="p.referencia || p.reference || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-gray-500" x-text="p.user_id || '—'"></td>
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="(p.moneda || '') + ' ' + (p.monto || p.amount || '—')"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="{
|
||||||
|
'bg-green-100 text-green-700': p.estado === 'completado',
|
||||||
|
'bg-yellow-100 text-yellow-700': p.estado === 'pendiente',
|
||||||
|
'bg-red-100 text-red-700': p.estado === 'fallido'
|
||||||
|
}" class="text-xs px-2 py-0.5 rounded-full" x-text="p.estado || '—'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-400" x-text="p.created_at ? p.created_at.substring(0,10) : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Transacciones ─── -->
|
||||||
|
<div x-show="tab === 'transacciones'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Tipo</th>
|
||||||
|
<th class="py-2 px-3">Monto</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
<th class="py-2 px-3">Descripción</th>
|
||||||
|
<th class="py-2 px-3">Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="t in items" :key="t.id || t._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono" x-text="t.type || '—'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="t.amount || '—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="{
|
||||||
|
'bg-green-100 text-green-700': t.status === 'completed',
|
||||||
|
'bg-yellow-100 text-yellow-700': t.status === 'pending',
|
||||||
|
'bg-red-100 text-red-700': t.status === 'failed'
|
||||||
|
}" class="text-xs px-2 py-0.5 rounded-full" x-text="t.status || '—'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-500 max-w-xs truncate" x-text="t.description || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-400" x-text="t.created_at ? t.created_at.substring(0,10) : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Logs ─── -->
|
||||||
|
<div x-show="tab === 'logs'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Tipo</th>
|
||||||
|
<th class="py-2 px-3">Usuario</th>
|
||||||
|
<th class="py-2 px-3">Descripción</th>
|
||||||
|
<th class="py-2 px-3">Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="l in items" :key="l.id || l._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded font-mono" x-text="l.type || l.tipo || '—'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-gray-500 text-xs" x-text="(l.user && l.user.email) ? l.user.email : (l.user_id || '—')"></td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-500 max-w-xs truncate" x-text="l.description || l.descripcion || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-400" x-text="l.created_at ? l.created_at.substring(0,10) : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: MiniWebs ─── -->
|
||||||
|
<div x-show="tab === 'miniwebs'">
|
||||||
|
<div x-show="items.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin datos. Haz clic en "Cargar".</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-auto w-full text-sm" x-show="items.length > 0">
|
||||||
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-3">Título</th>
|
||||||
|
<th class="py-2 px-3">Usuario</th>
|
||||||
|
<th class="py-2 px-3">VCard vinculada</th>
|
||||||
|
<th class="py-2 px-3">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="m in items" :key="m.id || m._id">
|
||||||
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td class="py-2 px-3 font-medium" x-text="m.titulo || m.title || '—'"></td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.user && m.user.email) ? m.user.email : (m.user_id || '—')"></td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.vcard && m.vcard.nombre) ? m.vcard.nombre : '—'"></td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span :class="m.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full" x-text="m.activo ? 'Activa' : 'Inactiva'"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación -->
|
||||||
|
<div x-show="total > 0" class="flex justify-between items-center mt-5 text-sm text-gray-500">
|
||||||
|
<span>Total: <b x-text="total"></b></span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button @click="changePage(page - 1)" :disabled="page <= 1" class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50">‹</button>
|
||||||
|
<span class="px-3 py-1" x-text="'Pág. ' + page + ' / ' + totalPages"></span>
|
||||||
|
<button @click="changePage(page + 1)" :disabled="page >= totalPages" class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Modal: Configurar API ─────────────────────────────────────────── -->
|
||||||
|
<div x-show="configModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div class="flex justify-between items-center p-5 border-b">
|
||||||
|
<h2 class="text-lg font-semibold">Configurar VCard API</h2>
|
||||||
|
<button @click="configModal = false" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre / Etiqueta</label>
|
||||||
|
<input x-model="form.nombre" type="text" placeholder="Ej: VCard Producción"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">URL Base <span class="text-red-500">*</span></label>
|
||||||
|
<input x-model="form.base_url" type="url" placeholder="https://midominio.com"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Sin barra final. Ej: https://midominio.com</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token <span class="text-red-500">*</span></label>
|
||||||
|
<input x-model="form.bearer_token" type="password" placeholder="1|abc123..."
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Token Sanctum de un usuario con rol administrador.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input x-model="form.activo" type="checkbox" id="vcardActivo" class="rounded" />
|
||||||
|
<label for="vcardActivo" class="text-sm text-gray-700">Integración activa</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="configError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="configError"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 px-5 pb-5">
|
||||||
|
<button @click="configModal = false" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||||
|
<button @click="saveConfig()" :disabled="saving"
|
||||||
|
class="px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
|
||||||
|
style="background-color:#8eb02f"
|
||||||
|
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
|
||||||
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||||
|
<span x-text="saving ? 'Guardando...' : 'Guardar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Modal: Membresía ──────────────────────────────────────────────── -->
|
||||||
|
<div x-show="membresiaModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div class="flex justify-between items-center p-5 border-b">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Gestionar Membresía</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5" x-text="membresiaUser ? (membresiaUser.name || membresiaUser.email) : ''"></p>
|
||||||
|
</div>
|
||||||
|
<button @click="membresiaModal = false" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<!-- Info membresía actual -->
|
||||||
|
<div x-show="membresiaInfo" class="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs space-y-1">
|
||||||
|
<div class="flex justify-between"><span class="font-medium">Plan:</span><span x-text="membresiaInfo && membresiaInfo.plan ? membresiaInfo.plan.nombre : '—'"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="font-medium">Vence:</span><span x-text="membresiaInfo && membresiaInfo.fecha_fin ? membresiaInfo.fecha_fin.substring(0,10) : '—'"></span></div>
|
||||||
|
<div class="flex justify-between"><span class="font-medium">Estado:</span>
|
||||||
|
<span :class="membresiaInfo && membresiaInfo.activo ? 'text-green-600' : 'text-red-500'"
|
||||||
|
x-text="membresiaInfo && membresiaInfo.activo ? 'Activa' : 'Inactiva'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Activar membresía -->
|
||||||
|
<div class="border rounded-lg p-3 space-y-2">
|
||||||
|
<p class="text-sm font-medium">Activar / Renovar</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input x-model="membresiaForm.anual" type="checkbox" id="mAnual" class="rounded" />
|
||||||
|
<label for="mAnual" class="text-sm text-gray-700">Anual (+365 días)</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="!membresiaForm.anual">
|
||||||
|
<input x-model.number="membresiaForm.dias" type="number" min="1" placeholder="Días a agregar"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
|
</div>
|
||||||
|
<button @click="activarMembresia()" :disabled="saving"
|
||||||
|
class="w-full px-4 py-2 text-sm text-white rounded-lg disabled:opacity-50"
|
||||||
|
style="background-color:#8eb02f">
|
||||||
|
<span x-text="saving ? 'Procesando...' : 'Activar / Renovar'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Cambiar plan -->
|
||||||
|
<div class="border rounded-lg p-3 space-y-2">
|
||||||
|
<p class="text-sm font-medium">Cambiar Plan</p>
|
||||||
|
<select x-model="membresiaForm.plan_id"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
||||||
|
<option value="">— Seleccionar plan —</option>
|
||||||
|
<template x-for="pl in planes" :key="pl.id || pl._id">
|
||||||
|
<option :value="pl.id || pl._id" x-text="pl.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<button @click="cambiarPlan()" :disabled="saving || !membresiaForm.plan_id"
|
||||||
|
class="w-full px-4 py-2 text-sm border border-blue-300 text-blue-600 rounded-lg hover:bg-blue-50 disabled:opacity-50">
|
||||||
|
<span x-text="saving ? 'Procesando...' : 'Cambiar Plan'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Desactivar -->
|
||||||
|
<div x-show="membresiaInfo && membresiaInfo.activo">
|
||||||
|
<button @click="desactivarMembresia()" :disabled="saving"
|
||||||
|
class="w-full px-4 py-2 text-sm border border-red-200 text-red-500 rounded-lg hover:bg-red-50 disabled:opacity-50">
|
||||||
|
Desactivar membresía
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="membresiaError" class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600" x-text="membresiaError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function vcardApiApp() {
|
||||||
|
return {
|
||||||
|
tab: 'usuarios',
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
perPage: 20,
|
||||||
|
search: '',
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
errorMsg: '',
|
||||||
|
hasConfig: false,
|
||||||
|
configData: null,
|
||||||
|
configModal: false,
|
||||||
|
configError: '',
|
||||||
|
form: { nombre: '', base_url: '', bearer_token: '', activo: true },
|
||||||
|
membresiaModal: false,
|
||||||
|
membresiaUser: null,
|
||||||
|
membresiaInfo: null,
|
||||||
|
membresiaForm: { anual: true, dias: 30, plan_id: '' },
|
||||||
|
membresiaError: '',
|
||||||
|
planes: [],
|
||||||
|
|
||||||
|
get totalPages() {
|
||||||
|
return Math.max(1, Math.ceil(this.total / this.perPage));
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadConfig();
|
||||||
|
if (this.hasConfig) this.loadTab();
|
||||||
|
this.loadPlanes();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadConfig() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/app/vcard-api/config');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.config) {
|
||||||
|
this.configData = d.config;
|
||||||
|
this.hasConfig = !!d.config.activo;
|
||||||
|
this.form.nombre = d.config.nombre || '';
|
||||||
|
this.form.base_url = d.config.base_url || '';
|
||||||
|
this.form.activo = d.config.activo ?? true;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveConfig() {
|
||||||
|
this.configError = '';
|
||||||
|
if (!this.form.base_url.trim()) { this.configError = 'La URL base es requerida.'; return; }
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/app/vcard-api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(this.form)
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
this.configModal = false;
|
||||||
|
await this.loadConfig();
|
||||||
|
if (this.hasConfig) this.loadTab();
|
||||||
|
} else {
|
||||||
|
this.configError = d.error || 'Error al guardar.';
|
||||||
|
}
|
||||||
|
} catch(e) { this.configError = e.message; }
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTab(t) {
|
||||||
|
this.tab = t;
|
||||||
|
this.page = 1;
|
||||||
|
this.items = [];
|
||||||
|
this.total = 0;
|
||||||
|
this.errorMsg = '';
|
||||||
|
if (this.hasConfig) this.loadTab();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTab() {
|
||||||
|
if (!this.hasConfig) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
const endpoints = {
|
||||||
|
usuarios: `/app/vcard-api/usuarios?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
vcards: `/app/vcard-api/vcards?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
planes: `/app/vcard-api/planes`,
|
||||||
|
pagos: `/app/vcard-api/pagos?per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
transacciones: `/app/vcard-api/transacciones?per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
logs: `/app/vcard-api/logs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
miniwebs: `/app/vcard-api/miniwebs?search=${encodeURIComponent(this.search)}&per_page=${this.perPage}&page=${this.page}`,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch(endpoints[this.tab]);
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) { this.errorMsg = d.error || `Error ${r.status}`; this.loading = false; return; }
|
||||||
|
// Laravel pagination o array plano
|
||||||
|
if (d.data && Array.isArray(d.data)) {
|
||||||
|
this.items = d.data;
|
||||||
|
this.total = d.total || d.data.length;
|
||||||
|
} else if (Array.isArray(d)) {
|
||||||
|
this.items = d;
|
||||||
|
this.total = d.length;
|
||||||
|
} else {
|
||||||
|
this.items = [];
|
||||||
|
this.total = 0;
|
||||||
|
this.errorMsg = 'Respuesta inesperada de la API.';
|
||||||
|
}
|
||||||
|
} catch(e) { this.errorMsg = e.message; }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPlanes() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/app/vcard-api/planes');
|
||||||
|
const d = await r.json();
|
||||||
|
if (Array.isArray(d)) this.planes = d;
|
||||||
|
else if (d.data) this.planes = d.data;
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
changePage(p) {
|
||||||
|
if (p < 1 || p > this.totalPages) return;
|
||||||
|
this.page = p;
|
||||||
|
this.loadTab();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleUsuario(u, accion) {
|
||||||
|
if (!confirm(`¿${accion === 'activar' ? 'Activar' : 'Desactivar'} este usuario?`)) return;
|
||||||
|
this.loading = true;
|
||||||
|
const id = u.id || u._id;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/app/vcard-api/usuarios/${id}/${accion}`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) { alert(d.error || 'Error al procesar.'); }
|
||||||
|
else await this.loadTab();
|
||||||
|
} catch(e) { alert(e.message); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async openMembresiaModal(u) {
|
||||||
|
this.membresiaUser = u;
|
||||||
|
this.membresiaInfo = null;
|
||||||
|
this.membresiaError = '';
|
||||||
|
this.membresiaForm = { anual: true, dias: 30, plan_id: '' };
|
||||||
|
this.membresiaModal = true;
|
||||||
|
try {
|
||||||
|
const id = u.id || u._id;
|
||||||
|
const r = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
|
||||||
|
const d = await r.json();
|
||||||
|
this.membresiaInfo = d;
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async activarMembresia() {
|
||||||
|
this.membresiaError = '';
|
||||||
|
this.saving = true;
|
||||||
|
const id = this.membresiaUser.id || this.membresiaUser._id;
|
||||||
|
const body = { anual: this.membresiaForm.anual };
|
||||||
|
if (!this.membresiaForm.anual) body.dias = this.membresiaForm.dias;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/app/vcard-api/usuarios/${id}/activar-membresia`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) this.membresiaError = d.error || d.message || 'Error al activar.';
|
||||||
|
else {
|
||||||
|
const rInfo = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
|
||||||
|
this.membresiaInfo = await rInfo.json();
|
||||||
|
}
|
||||||
|
} catch(e) { this.membresiaError = e.message; }
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async desactivarMembresia() {
|
||||||
|
if (!confirm('¿Desactivar membresía?')) return;
|
||||||
|
this.membresiaError = '';
|
||||||
|
this.saving = true;
|
||||||
|
const id = this.membresiaUser.id || this.membresiaUser._id;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/app/vcard-api/usuarios/${id}/desactivar-membresia`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) this.membresiaError = d.error || 'Error al desactivar.';
|
||||||
|
else this.membresiaInfo = { ...this.membresiaInfo, activo: false };
|
||||||
|
} catch(e) { this.membresiaError = e.message; }
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cambiarPlan() {
|
||||||
|
if (!this.membresiaForm.plan_id) return;
|
||||||
|
this.membresiaError = '';
|
||||||
|
this.saving = true;
|
||||||
|
const id = this.membresiaUser.id || this.membresiaUser._id;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/app/vcard-api/usuarios/${id}/cambiar-plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ plan_id: this.membresiaForm.plan_id, anual: this.membresiaForm.anual })
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) this.membresiaError = d.error || d.message || 'Error al cambiar plan.';
|
||||||
|
else {
|
||||||
|
const rInfo = await fetch(`/app/vcard-api/usuarios/${id}/membresia`);
|
||||||
|
this.membresiaInfo = await rInfo.json();
|
||||||
|
}
|
||||||
|
} catch(e) { this.membresiaError = e.message; }
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VcardApiIndex renderiza el panel de integración VCard.
|
||||||
|
func VcardApiIndex(c *fiber.Ctx) error {
|
||||||
|
cfg, _ := models.GetVcardApiConfig()
|
||||||
|
data := fiber.Map{
|
||||||
|
"user": c.Locals("user").(map[string]interface{}),
|
||||||
|
"modules": c.Locals("userModules"),
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
return c.Render("vcard_api", data, "layouts/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
// VcardApiSaveConfig guarda / actualiza la configuración (base URL + token).
|
||||||
|
func VcardApiSaveConfig(c *fiber.Ctx) error {
|
||||||
|
type Req struct {
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
BearerToken string `json:"bearer_token"`
|
||||||
|
Activo bool `json:"activo"`
|
||||||
|
}
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.BaseURL) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "base_url es requerido"})
|
||||||
|
}
|
||||||
|
cfg, err := models.UpsertVcardApiConfig(req.Nombre, strings.TrimRight(strings.TrimSpace(req.BaseURL), "/"), req.BearerToken, req.Activo)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "config": cfg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VcardApiGetConfig devuelve la configuración actual (sin exponer el token completo).
|
||||||
|
func VcardApiGetConfig(c *fiber.Ctx) error {
|
||||||
|
cfg, err := models.GetVcardApiConfig()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{"config": nil})
|
||||||
|
}
|
||||||
|
masked := "••••••••"
|
||||||
|
if cfg.BearerToken == "" {
|
||||||
|
masked = ""
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"config": fiber.Map{
|
||||||
|
"ID": cfg.ID,
|
||||||
|
"nombre": cfg.Nombre,
|
||||||
|
"base_url": cfg.BaseURL,
|
||||||
|
"bearer_token": masked,
|
||||||
|
"activo": cfg.Activo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// vcardDo ejecuta una llamada a la API Admin VCard y devuelve el body como json.RawMessage.
|
||||||
|
func vcardDo(method, endpoint string, cfg *models.VcardApiConfig) ([]byte, int, error) {
|
||||||
|
return vcardDoWithBody(method, endpoint, nil, "", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vcardDoWithBody ejecuta una llamada a la API Admin VCard con body opcional.
|
||||||
|
func vcardDoWithBody(method, endpoint string, reqBody io.Reader, contentType string, cfg *models.VcardApiConfig) ([]byte, int, error) {
|
||||||
|
url := fmt.Sprintf("%s%s", cfg.BaseURL, endpoint)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest(method, url, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.BearerToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyVcard extrae la config, llama al endpoint y devuelve el resultado al frontend.
|
||||||
|
func proxyVcard(c *fiber.Ctx, method, endpoint string) error {
|
||||||
|
cfg, err := models.GetVcardApiConfig()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Configura primero la API VCard"})
|
||||||
|
}
|
||||||
|
if !cfg.Activo {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración está inactiva"})
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := string(c.Request().URI().QueryString())
|
||||||
|
ep := endpoint
|
||||||
|
if qs != "" {
|
||||||
|
ep = endpoint + "?" + qs
|
||||||
|
}
|
||||||
|
|
||||||
|
body, status, err := vcardDo(method, ep, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
var result json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
result = json.RawMessage(fmt.Sprintf(`{"raw": %q}`, string(body)))
|
||||||
|
}
|
||||||
|
c.Status(status)
|
||||||
|
return c.JSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyVcardMutate reenvía el body del request original a la API VCard.
|
||||||
|
func proxyVcardMutate(c *fiber.Ctx, method, endpoint string) error {
|
||||||
|
cfg, err := models.GetVcardApiConfig()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Configura primero la API VCard"})
|
||||||
|
}
|
||||||
|
if !cfg.Activo {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración está inactiva"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
ct := ""
|
||||||
|
if raw := c.Body(); len(raw) > 0 {
|
||||||
|
bodyReader = strings.NewReader(string(raw))
|
||||||
|
ct = c.Get("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, status, err := vcardDoWithBody(method, endpoint, bodyReader, ct, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
var result json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
result = json.RawMessage(fmt.Sprintf(`{"raw": %q}`, string(body)))
|
||||||
|
}
|
||||||
|
c.Status(status)
|
||||||
|
return c.JSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proxy endpoints ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func VcardApiUsuarios(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiVcards(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/vcards")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiVcard(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/vcards/"+c.Params("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiVcardsByUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("userId")+"/vcards")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiPlanes(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/planes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiMembresia(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("id")+"/membresia")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiPagos(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/pagos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiPagosByUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("userId")+"/pagos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiTransacciones(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/transacciones")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiTransaccionesByUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("userId")+"/transacciones")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiLogs(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiLogsByUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("userId")+"/logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiMiniwebs(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/miniwebs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiMiniweb(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/miniwebs/"+c.Params("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiMiniwebsByUsuario(c *fiber.Ctx) error {
|
||||||
|
return proxyVcard(c, http.MethodGet, "/api/admin/usuarios/"+c.Params("userId")+"/miniwebs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutación endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func VcardApiUsuarioUpdate(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPut, "/api/admin/usuarios/"+c.Params("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiUsuarioActivar(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPost, "/api/admin/usuarios/"+c.Params("id")+"/activar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiUsuarioDesactivar(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPost, "/api/admin/usuarios/"+c.Params("id")+"/desactivar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiVcardUpdate(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPut, "/api/admin/vcards/"+c.Params("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiActivarMembresia(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPost, "/api/admin/usuarios/"+c.Params("id")+"/activar-membresia")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiDesactivarMembresia(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPost, "/api/admin/usuarios/"+c.Params("id")+"/desactivar-membresia")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VcardApiCambiarPlan(c *fiber.Ctx) error {
|
||||||
|
return proxyVcardMutate(c, http.MethodPost, "/api/admin/usuarios/"+c.Params("id")+"/cambiar-plan")
|
||||||
|
}
|
||||||
+33
-1
@@ -21,7 +21,7 @@ func UserRoutes(app fiber.Router) {
|
|||||||
|
|
||||||
// Rutas de la aplicación
|
// Rutas de la aplicación
|
||||||
|
|
||||||
// web me redireccione a /
|
// web me redireccione a /
|
||||||
protected.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
protected.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
||||||
app.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
app.Get("/web", func(c *fiber.Ctx) error { return c.Redirect("/", http.StatusSeeOther) })
|
||||||
protected.Get("/", controllers.App)
|
protected.Get("/", controllers.App)
|
||||||
@@ -215,6 +215,38 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
|
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
|
||||||
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
|
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
|
||||||
|
|
||||||
|
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
|
||||||
|
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
|
||||||
|
protected.Get("/vcard-api/config", controllers.VcardApiGetConfig)
|
||||||
|
protected.Post("/vcard-api/config", controllers.VcardApiSaveConfig)
|
||||||
|
// Usuarios (GET)
|
||||||
|
protected.Get("/vcard-api/usuarios", controllers.VcardApiUsuarios)
|
||||||
|
protected.Get("/vcard-api/usuarios/:id/membresia", controllers.VcardApiMembresia)
|
||||||
|
protected.Get("/vcard-api/usuarios/:userId/vcards", controllers.VcardApiVcardsByUsuario)
|
||||||
|
protected.Get("/vcard-api/usuarios/:userId/pagos", controllers.VcardApiPagosByUsuario)
|
||||||
|
protected.Get("/vcard-api/usuarios/:userId/transacciones", controllers.VcardApiTransaccionesByUsuario)
|
||||||
|
protected.Get("/vcard-api/usuarios/:userId/logs", controllers.VcardApiLogsByUsuario)
|
||||||
|
protected.Get("/vcard-api/usuarios/:userId/miniwebs", controllers.VcardApiMiniwebsByUsuario)
|
||||||
|
protected.Get("/vcard-api/usuarios/:id", controllers.VcardApiUsuario)
|
||||||
|
// Usuarios (mutaciones)
|
||||||
|
protected.Put("/vcard-api/usuarios/:id", controllers.VcardApiUsuarioUpdate)
|
||||||
|
protected.Post("/vcard-api/usuarios/:id/activar", controllers.VcardApiUsuarioActivar)
|
||||||
|
protected.Post("/vcard-api/usuarios/:id/desactivar", controllers.VcardApiUsuarioDesactivar)
|
||||||
|
protected.Post("/vcard-api/usuarios/:id/activar-membresia", controllers.VcardApiActivarMembresia)
|
||||||
|
protected.Post("/vcard-api/usuarios/:id/desactivar-membresia", controllers.VcardApiDesactivarMembresia)
|
||||||
|
protected.Post("/vcard-api/usuarios/:id/cambiar-plan", controllers.VcardApiCambiarPlan)
|
||||||
|
// VCards
|
||||||
|
protected.Get("/vcard-api/vcards", controllers.VcardApiVcards)
|
||||||
|
protected.Get("/vcard-api/vcards/:id", controllers.VcardApiVcard)
|
||||||
|
protected.Put("/vcard-api/vcards/:id", controllers.VcardApiVcardUpdate)
|
||||||
|
// Resto (solo GET)
|
||||||
|
protected.Get("/vcard-api/planes", controllers.VcardApiPlanes)
|
||||||
|
protected.Get("/vcard-api/pagos", controllers.VcardApiPagos)
|
||||||
|
protected.Get("/vcard-api/transacciones", controllers.VcardApiTransacciones)
|
||||||
|
protected.Get("/vcard-api/logs", controllers.VcardApiLogs)
|
||||||
|
protected.Get("/vcard-api/miniwebs", controllers.VcardApiMiniwebs)
|
||||||
|
protected.Get("/vcard-api/miniwebs/:id", controllers.VcardApiMiniweb)
|
||||||
|
|
||||||
// ─── Telegram ─────────────────────────────────────────────────────────────
|
// ─── Telegram ─────────────────────────────────────────────────────────────
|
||||||
protected.Get("/telegram", middlewares.MenuMiddleware, controllers.TelegramIndex)
|
protected.Get("/telegram", middlewares.MenuMiddleware, controllers.TelegramIndex)
|
||||||
protected.Get("/loadtelegram", controllers.GetTelegramConfigs)
|
protected.Get("/loadtelegram", controllers.GetTelegramConfigs)
|
||||||
|
|||||||
+13
-2
@@ -35,12 +35,23 @@ WHERE NOT EXISTS (
|
|||||||
SELECT 1 FROM submodules WHERE url = '/app/cloudflare' AND deleted_at IS NULL
|
SELECT 1 FROM submodules WHERE url = '/app/cloudflare' AND deleted_at IS NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 4. Asignar ambos submódulos a TODOS los roles (ignorar duplicados)
|
-- 4. Insertar submódulo VCard API si no existe
|
||||||
|
INSERT INTO submodules (title, description, url, module_id, modified_at, created_at, updated_at)
|
||||||
|
SELECT 'VCard API',
|
||||||
|
'Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más',
|
||||||
|
'/app/vcard-api',
|
||||||
|
(SELECT id FROM modules WHERE title = 'Integraciones' AND deleted_at IS NULL LIMIT 1),
|
||||||
|
NOW(), NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM submodules WHERE url = '/app/vcard-api' AND deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Asignar todos los submódulos de Integraciones a TODOS los roles (ignorar duplicados)
|
||||||
INSERT INTO roles_submodules (role_id, submodule_id)
|
INSERT INTO roles_submodules (role_id, submodule_id)
|
||||||
SELECT r.id, s.id
|
SELECT r.id, s.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
CROSS JOIN submodules s
|
CROSS JOIN submodules s
|
||||||
WHERE s.url IN ('/app/hostinger', '/app/cloudflare')
|
WHERE s.url IN ('/app/hostinger', '/app/cloudflare', '/app/vcard-api')
|
||||||
AND r.deleted_at IS NULL
|
AND r.deleted_at IS NULL
|
||||||
AND s.deleted_at IS NULL
|
AND s.deleted_at IS NULL
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|||||||
Reference in New Issue
Block a user