Base para que el cliente administre uMind desde su portal y para el cobro por consumo. Todavía no cambia nada del comportamiento actual. - UmindTenant gana ClienteID (punteros, sin not null: los tenants creados antes del portal quedan sin asignar y el ALTER TABLE no falla sobre datos existentes) y PlanID. - GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada. - UmindPlan define el máximo de agentes y los precios por 1k tokens, por imagen OCR, por transcripción y la mensualidad, más un tope de consumo que solo avisa. CRUD para staff en /app/umind-planes. - El modelo va en AMBAS listas de AutoMigrate (main.go y migrations), no repetir el error de agregarlo solo en una. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
8.9 KiB
HTML
178 lines
8.9 KiB
HTML
<div x-data="umindPlanesApp()" x-init="init()" class="p-6 max-w-6xl mx-auto">
|
|
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Planes de uMind</h1>
|
|
<p class="text-sm text-slate-500 mt-1">Límite de agentes y precios por consumo que se aplican a cada tenant</p>
|
|
</div>
|
|
<button class="btn-primary" @click="nuevo()">+ Nuevo plan</button>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<th class="px-3 py-2 text-left">Plan</th>
|
|
<th class="px-3 py-2 text-right">Agentes</th>
|
|
<th class="px-3 py-2 text-right">Mensualidad</th>
|
|
<th class="px-3 py-2 text-right">1k tokens</th>
|
|
<th class="px-3 py-2 text-right">OCR</th>
|
|
<th class="px-3 py-2 text-right">Transcripción</th>
|
|
<th class="px-3 py-2 text-right">Tope</th>
|
|
<th class="px-3 py-2 text-left">Estado</th>
|
|
<th class="px-3 py-2"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
<template x-for="p in planes" :key="p.ID">
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-3 py-2 font-medium text-slate-700" x-text="p.nombre"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="p.max_agentes === 0 ? 'ilimitado' : p.max_agentes"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_mensual, p.moneda)"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_1k_tokens, p.moneda)"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_ocr, p.moneda)"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_transcripcion, p.moneda)"></td>
|
|
<td class="px-3 py-2 text-right text-slate-600" x-text="p.tope_consumo_mensual ? money(p.tope_consumo_mensual, p.moneda) : '—'"></td>
|
|
<td class="px-3 py-2">
|
|
<span class="badge" :class="p.activo?'badge-green':'badge-slate'" x-text="p.activo?'activo':'inactivo'"></span>
|
|
</td>
|
|
<td class="px-3 py-2 text-right whitespace-nowrap">
|
|
<button class="text-slate-400 hover:text-slate-700 px-1" @click="editar(p)">Editar</button>
|
|
<button class="text-red-500 hover:text-red-700 px-1" @click="eliminar(p)">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template x-if="!planes.length">
|
|
<tr><td colspan="9" class="text-center py-8 text-slate-400">Sin planes todavía. Creá el primero para poder asignarlo a un tenant.</td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
|
|
|
<!-- Modal -->
|
|
<div x-show="showForm" x-cloak class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm=false">
|
|
<div class="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<h2 class="text-lg font-bold text-slate-800 mb-4" x-text="form.id?'Editar plan':'Nuevo plan'"></h2>
|
|
<form @submit.prevent="guardar()">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="col-span-2">
|
|
<label class="label">Nombre</label>
|
|
<input x-model="form.nombre" class="input-field w-full" required placeholder="Básico">
|
|
</div>
|
|
<div>
|
|
<label class="label">Máximo de agentes</label>
|
|
<input x-model.number="form.max_agentes" type="number" min="0" class="input-field w-full">
|
|
<p class="text-xs text-slate-400 mt-1">0 = ilimitado</p>
|
|
</div>
|
|
<div>
|
|
<label class="label">Moneda</label>
|
|
<input x-model="form.moneda" maxlength="3" class="input-field w-full" placeholder="COP">
|
|
</div>
|
|
<div>
|
|
<label class="label">Mensualidad</label>
|
|
<input x-model.number="form.precio_mensual" type="number" step="any" min="0" class="input-field w-full">
|
|
</div>
|
|
<div>
|
|
<label class="label">Precio por 1.000 tokens</label>
|
|
<input x-model.number="form.precio_por_1k_tokens" type="number" step="any" min="0" class="input-field w-full">
|
|
</div>
|
|
<div>
|
|
<label class="label">Precio por imagen (OCR)</label>
|
|
<input x-model.number="form.precio_por_ocr" type="number" step="any" min="0" class="input-field w-full">
|
|
</div>
|
|
<div>
|
|
<label class="label">Precio por transcripción</label>
|
|
<input x-model.number="form.precio_por_transcripcion" type="number" step="any" min="0" class="input-field w-full">
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="label">Tope de consumo mensual</label>
|
|
<input x-model.number="form.tope_consumo_mensual" type="number" step="any" min="0" class="input-field w-full">
|
|
<p class="text-xs text-slate-400 mt-1">Al superarlo se avisa al cliente y al staff, pero el servicio sigue funcionando. 0 = sin tope.</p>
|
|
</div>
|
|
<div class="col-span-2" x-show="form.id">
|
|
<label class="flex items-center gap-2 text-sm text-slate-600">
|
|
<input type="checkbox" x-model="form.activo" class="rounded border-slate-300">
|
|
Plan activo
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
|
<div class="flex justify-end gap-2 mt-5">
|
|
<button type="button" class="btn-secondary" @click="showForm=false">Cancelar</button>
|
|
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
function umindPlanesApp() {
|
|
return {
|
|
planes: [], showForm:false, saving:false, error:'',
|
|
form: vacio(),
|
|
|
|
async init(){ await this.cargar(); },
|
|
|
|
async cargar(){
|
|
try { const r = await axios.get('/app/umind-planes/list'); this.planes = r.data.items || []; }
|
|
catch(e){ this.error = e.response?.data?.error || 'Error al cargar'; }
|
|
},
|
|
|
|
nuevo(){ this.form = vacio(); this.error=''; this.showForm = true; },
|
|
|
|
editar(p){
|
|
this.form = {
|
|
id: p.ID, nombre: p.nombre, max_agentes: p.max_agentes,
|
|
precio_mensual: p.precio_mensual, precio_por_1k_tokens: p.precio_por_1k_tokens,
|
|
precio_por_ocr: p.precio_por_ocr, precio_por_transcripcion: p.precio_por_transcripcion,
|
|
moneda: p.moneda, tope_consumo_mensual: p.tope_consumo_mensual, activo: p.activo,
|
|
};
|
|
this.error=''; this.showForm = true;
|
|
},
|
|
|
|
async guardar(){
|
|
this.saving = true; this.error = '';
|
|
try {
|
|
if (this.form.id) await axios.put('/app/umind-planes/' + this.form.id, this.form);
|
|
else await axios.post('/app/umind-planes', this.form);
|
|
this.showForm = false;
|
|
await this.cargar();
|
|
} catch(e){ this.error = e.response?.data?.error || 'Error al guardar'; }
|
|
finally { this.saving = false; }
|
|
},
|
|
|
|
async eliminar(p){
|
|
if (!confirm('¿Eliminar el plan "' + p.nombre + '"? Los tenants que lo tengan asignado quedan sin plan.')) return;
|
|
try { await axios.delete('/app/umind-planes/' + p.ID); await this.cargar(); }
|
|
catch(e){ this.error = e.response?.data?.error || 'Error al eliminar'; }
|
|
},
|
|
|
|
money(v, m){ return (m||'COP') + ' ' + Number(v||0).toLocaleString('es-CO', {maximumFractionDigits:4}); },
|
|
}
|
|
}
|
|
function vacio(){
|
|
return { id:0, nombre:'', max_agentes:1, precio_mensual:0, precio_por_1k_tokens:0,
|
|
precio_por_ocr:0, precio_por_transcripcion:0, moneda:'COP', tope_consumo_mensual:0, activo:true };
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
[x-cloak] { display: none !important; }
|
|
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; transition:background .15s; }
|
|
.btn-primary:hover { background:#6d8c24; }
|
|
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
|
|
.btn-secondary { background:#f1f5f9; color:#475569; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:500; font-size:.875rem; border:1px solid #e2e8f0; }
|
|
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; transition:border-color .15s; }
|
|
.input-field:focus { border-color:#8eb02f; }
|
|
.label { display:block; font-size:.75rem; font-weight:600; color:#475569; margin-bottom:.25rem; text-transform:uppercase; letter-spacing:.05em; }
|
|
.badge { display:inline-block; padding:.15rem .6rem; border-radius:9999px; font-size:.7rem; font-weight:600; }
|
|
.badge-green { background:#dcfce7; color:#15803d; }
|
|
.badge-slate { background:#f1f5f9; color:#475569; }
|
|
</style>
|