Files
soft_usite/resources/views/tickets_admin.html
T
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
En cada ticket, " Borrador" propone una respuesta usando los fragmentos más
parecidos de la base de conocimiento del agente de uMind que se elija en la
configuración de soporte, más el hilo de la conversación.

El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona
y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que
nadie lo lea, es la forma más rápida de perderlo.

Sin agente configurado el borrador se arma igual, solo con la conversación —
peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o
pasos que no estén en la documentación, y le pide decir qué falta preguntar
cuando no alcanza para resolver.

El cuadro de respuesta pasa a textarea: un borrador de varios renglones no
entraba en un input de una línea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:45:38 -05:00

243 lines
10 KiB
HTML

<div x-data="ticketsAdmin()" x-init="init()" class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-slate-800">Tickets de soporte</h1>
<p class="text-sm text-slate-500 mt-0.5">Todos los tickets abiertos por clientes del portal y correo</p>
</div>
<div class="flex items-center gap-2">
<template x-for="s in estados" :key="s.val">
<button @click="filtro=s.val; load()"
:class="filtro===s.val ? 'text-white' : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'"
:style="filtro===s.val ? 'background:#8eb02f' : ''"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
x-text="s.label">
</button>
</template>
</div>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-slate-400 text-sm">Cargando tickets...</div>
<!-- Empty -->
<div x-show="!loading && tickets.length===0" class="text-center py-12 text-slate-400 text-sm">
No hay tickets con este filtro.
</div>
<!-- Lista -->
<div x-show="!loading" class="space-y-3">
<template x-for="t in tickets" :key="t.ID">
<div :id="'ticket-' + t.ID" class="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<!-- Cabecera del ticket -->
<div class="p-4 flex items-start gap-4 cursor-pointer" @click="t._open = !t._open">
<div class="mt-0.5 w-2.5 h-2.5 rounded-full flex-shrink-0" :class="dotClass(t.estado)"></div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="badge" :class="ticketBadge(t.estado)" x-text="t.estado"></span>
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
<span x-show="t.categoria" class="badge badge-default text-xs" x-text="t.categoria"></span>
<!-- De quién es. Sin cliente resuelto es alguien de afuera: no es
un error, pero conviene que se vea distinto. -->
<span x-show="t.cliente" class="badge text-xs bg-indigo-100 text-indigo-700"
x-text="t.cliente ? '🏢 ' + t.cliente.nombre : ''"></span>
<span x-show="!t.cliente && t.origen==='email'" class="badge text-xs bg-slate-100 text-slate-500">👤 Externo</span>
</div>
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
<p class="text-xs text-slate-500 mt-0.5">
<span x-text="t.autor_nombre"></span>
<span x-show="t.email_from" class="text-slate-400">&lt;<span x-text="t.email_from"></span>&gt;</span>
<span class="mx-1">&middot;</span>
<span x-text="formatDate(t.CreatedAt)"></span>
<span x-show="t.mensajes && t.mensajes.length" class="ml-2 text-slate-400">
💬 <span x-text="t.mensajes ? t.mensajes.length : 0"></span>
</span>
<span x-show="t.asignado" class="ml-2 text-slate-400">
👤 <span x-text="t.asignado.name"></span>
</span>
</p>
</div>
<!-- Acciones -->
<div class="flex items-center gap-2 flex-shrink-0" @click.stop>
<select @change="cambiarEstado(t, $event.target.value)"
class="text-xs border border-slate-200 rounded-lg px-2 py-1.5 outline-none bg-white text-slate-700">
<option value="abierto" :selected="t.estado==='abierto'">Abierto</option>
<option value="en_progreso" :selected="t.estado==='en_progreso'">En progreso</option>
<option value="resuelto" :selected="t.estado==='resuelto'">Resuelto</option>
<option value="cerrado" :selected="t.estado==='cerrado'">Cerrado</option>
</select>
<!-- Asignar -->
<select @change="asignar(t, $event.target.value)"
class="text-xs border border-slate-200 rounded-lg px-2 py-1.5 outline-none bg-white text-slate-700">
<option value="">Sin asignar</option>
<template x-for="a in admins" :key="a.id">
<option :value="a.id" :selected="t.asignado_a===a.id" x-text="a.name"></option>
</template>
</select>
</div>
</div>
<!-- Detalle expandido -->
<div x-show="t._open" class="border-t border-slate-100 px-4 pb-4">
<p class="text-sm text-slate-600 py-3 border-b border-slate-100" x-text="t.descripcion"></p>
<!-- Mensajes -->
<template x-if="t.mensajes && t.mensajes.length">
<div class="space-y-2 mt-3">
<template x-for="m in t.mensajes" :key="m.ID">
<div class="flex gap-2" :class="m.es_admin ? 'justify-end' : ''">
<div :class="m.es_admin ? 'bg-primary/10' : 'bg-slate-100'"
class="rounded-xl px-3 py-2 text-sm max-w-sm text-slate-700">
<div class="text-xs font-semibold mb-1"
:style="m.es_admin ? 'color:#8eb02f' : 'color:#64748b'"
x-text="m.autor_nombre"></div>
<span x-text="m.contenido"></span>
<div class="text-xs text-slate-400 mt-1" x-text="formatDate(m.CreatedAt)"></div>
</div>
</div>
</template>
</div>
</template>
<!-- Responder -->
<div class="mt-3 flex gap-2">
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
@keydown.enter.exact.prevent="responder(t)"
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
<button @click="redactar(t)" :disabled="t._redactando"
title="Propone una respuesta con la base de conocimiento. No la envía."
class="px-3 py-2 rounded-xl border border-slate-300 text-sm text-slate-700 disabled:opacity-50 flex-shrink-0">
<span x-text="t._redactando ? 'Redactando…' : '✨ Borrador'"></span>
</button>
<button @click="responder(t)"
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
style="background:#8eb02f"
onmouseover="this.style.background='#6d8c24'" onmouseout="this.style.background='#8eb02f'">
Responder
</button>
</div>
</div>
</div>
</template>
</div>
<!-- Paginación simple -->
<div x-show="!loading && tickets.length > 0" class="mt-4 text-xs text-slate-400 text-center">
Mostrando <span x-text="tickets.length"></span> tickets
</div>
</div>
<script>
function ticketsAdmin() {
return {
tickets: [],
admins: [],
loading: true,
filtro: 'todos',
estados: [
{ val: 'todos', label: 'Todos' },
{ val: 'abierto', label: 'Abiertos' },
{ val: 'en_progreso',label: 'En progreso' },
{ val: 'resuelto', label: 'Resueltos' },
{ val: 'cerrado', label: 'Cerrados' },
],
async init() {
await this.cargarAdmins();
const params = new URLSearchParams(window.location.search);
if (params.get('ticket')) this.filtro = 'todos';
await this.load();
this.openTicketFromQuery();
},
async cargarAdmins() {
try {
const r = await axios.get('/app/tickets/admins');
this.admins = r.data || [];
} catch {}
},
async load() {
this.loading = true;
try {
const r = await axios.get(`/app/tickets/data?estado=${this.filtro}`);
this.tickets = (r.data || []).map(t => ({ ...t, _open: false, _reply: '' }));
} finally {
this.loading = false;
}
},
openTicketFromQuery() {
const ticketId = new URLSearchParams(window.location.search).get('ticket');
if (!ticketId) return;
const t = this.tickets.find(x => String(x.ID) === String(ticketId));
if (!t) return;
t._open = true;
this.$nextTick(() => {
const el = document.getElementById('ticket-' + t.ID);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
},
async cambiarEstado(t, estado) {
await axios.put(`/app/tickets/${t.ID}/estado`, { estado });
t.estado = estado;
},
async asignar(t, userId) {
if (!userId) {
await axios.put(`/app/tickets/${t.ID}/asignar`, { asignado_id: 0 });
t.asignado_a = null;
t.asignado = null;
} else {
const id = parseInt(userId);
await axios.put(`/app/tickets/${t.ID}/asignar`, { asignado_id: id });
t.asignado_a = id;
const a = this.admins.find(x => x.id === id);
t.asignado = a ? { name: a.name } : null;
}
},
async redactar(t) {
t._redactando = true;
try {
const { data } = await axios.post(`/app/tickets/${t.ID}/borrador`);
// Se escribe en el cuadro de respuesta, no se envía: lo revisa una persona.
t._reply = data.borrador || '';
} catch (e) {
alert(e.response?.data?.error || e.message);
}
t._redactando = false;
},
async responder(t) {
const contenido = (t._reply || '').trim();
if (!contenido) return;
await axios.post(`/app/tickets/${t.ID}/mensaje`, { contenido });
if (!t.mensajes) t.mensajes = [];
t.mensajes.push({ ID: Date.now(), contenido, es_admin: true, autor_nombre: 'Admin', CreatedAt: new Date().toISOString() });
t._reply = '';
t._open = true;
},
dotClass(e) {
return { 'bg-yellow-400': e==='abierto', 'bg-blue-400': e==='en_progreso', 'bg-green-400': e==='resuelto', 'bg-slate-300': e==='cerrado' };
},
ticketBadge(e) {
return { 'badge-warning': e==='abierto', 'badge-info': e==='en_progreso', 'badge-success': e==='resuelto', 'badge-default': e==='cerrado' };
},
prioridadBadge(p) {
return { 'badge-danger': p==='urgente', 'badge-warning': p==='alta', 'badge-info': p==='media', 'badge-default': p==='baja' };
},
formatDate(d) {
if (!d) return '';
return new Date(d).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' });
},
};
}
</script>