Auditoría de /portal/dashboard. Cuatro problemas, todos en el mismo bucle: 1. El avance se veía un render atrasado. ActualizarProgresoProyecto corría DESPUÉS de cargar los proyectos: escribía el valor nuevo en la base pero los structs ya cargados seguían con el viejo, que es lo que se renderiza. El usuario veía el cálculo de la visita anterior. 2. N+1 con escrituras en un GET: dos COUNT y un UPDATE por proyecto. Con 10 proyectos, 30 consultas y 10 escrituras por cada carga del dashboard — incluyendo las de cualquier bot que pase. Ahora es UNA consulta agrupada y ninguna escritura; los caminos que tocan una fase ya mantienen la columna al día, así que recalcular en el GET no aportaba nada. 3. Orden aleatorio de los grupos: se recorría un map de Go, así que un partner veía sus clientes en distinto orden en cada recarga. 4. isPartner se decidía con u.Rol mientras el alcance se decidía con Role.EsPortalPartner. Desincronizados, un usuario veía proyectos de varios clientes sin agrupar, o la vista agrupada vacía. Ahora hay una sola definición (PortalUser.EsPartner) con test de los dos sentidos. Además, el error de carga se descartaba con `_` y el usuario terminaba viendo "no tenés proyectos", indistinguible de una caída de la base. Sin hallazgos de seguridad: el chequeo de acceso por cliente está en todas las rutas, la sesión revalida Activo en cada request, y las plantillas no usan x-html ni template.HTML, así que Go escapa todo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
11 KiB
HTML
230 lines
11 KiB
HTML
<!-- Portal Dashboard -->
|
|
<div x-data="portalDashboard()" x-init="init()">
|
|
|
|
<!-- Saludo -->
|
|
<div class="mb-8">
|
|
<h1 class="text-2xl font-bold text-slate-800">
|
|
Hola, {{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }} 👋
|
|
</h1>
|
|
<p class="text-slate-500 text-sm mt-1">
|
|
{{ if .isPartner }}
|
|
Estás viendo todos los proyectos de tus clientes.
|
|
{{ else }}
|
|
Aquí puedes ver el avance de tus proyectos.
|
|
{{ end }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Un fallo al cargar tiene que verse como un fallo, no como "no tenés
|
|
proyectos": son dos situaciones muy distintas para quien mira. -->
|
|
{{ if .error }}
|
|
<div class="bg-red-50 border border-red-200 text-red-700 rounded-xl p-4 text-sm mb-6">
|
|
{{ .error }}
|
|
</div>
|
|
{{ end }}
|
|
|
|
<!-- Proyectos cards -->
|
|
{{ if .isPartner }}
|
|
<!-- Agrupado por cliente -->
|
|
{{ range .grupos }}
|
|
<div class="mb-8">
|
|
<h2 class="text-sm font-bold text-slate-500 uppercase tracking-wide mb-3">
|
|
{{ if .Cliente.Empresa }}{{ .Cliente.Empresa }}{{ else }}{{ .Cliente.Nombre }}{{ end }}
|
|
</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{{ range .Proyectos }}
|
|
<a href="/portal/proyecto/{{ .Slug }}" class="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow p-5 block group">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<span class="w-3 h-3 rounded-full" style="background:{{ .Color }}"></span>
|
|
<span class="font-semibold text-slate-800 group-hover:text-[#8eb02f] transition-colors text-sm">{{ .Nombre }}</span>
|
|
</div>
|
|
<p class="text-xs text-slate-500 mb-3 line-clamp-2">{{ .Descripcion }}</p>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs font-medium text-slate-500">Progreso</span>
|
|
<span class="text-xs font-bold text-slate-700">{{ .Progreso }}%</span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-2">
|
|
<div class="h-2 rounded-full" style="background:{{ .Color }};width:{{ .Progreso }}%"></div>
|
|
</div>
|
|
<div class="mt-3 flex justify-between items-center">
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="background:#f1f5f9;color:#475569">{{ .Estado }}</span>
|
|
<span class="text-xs text-slate-400">Ver detalle →</span>
|
|
</div>
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
{{ else }}
|
|
<!-- Vista simple para cliente -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{{ range .proyectos }}
|
|
<a href="/portal/proyecto/{{ .Slug }}" class="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow p-5 block group">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<span class="w-3 h-3 rounded-full" style="background:{{ .Color }}"></span>
|
|
<span class="font-semibold text-slate-800 group-hover:text-[#8eb02f] transition-colors text-sm">{{ .Nombre }}</span>
|
|
</div>
|
|
<p class="text-xs text-slate-500 mb-3 line-clamp-2">{{ .Descripcion }}</p>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs font-medium text-slate-500">Progreso</span>
|
|
<span class="text-xs font-bold text-slate-700">{{ .Progreso }}%</span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-2">
|
|
<div class="h-2 rounded-full" style="background:{{ .Color }};width:{{ .Progreso }}%"></div>
|
|
</div>
|
|
<div class="mt-3 flex justify-between items-center">
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="background:#f1f5f9;color:#475569">{{ .Estado }}</span>
|
|
<span class="text-xs text-slate-400">Ver detalle →</span>
|
|
</div>
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
{{ if not .proyectos }}
|
|
<div class="text-center py-20 text-slate-400">
|
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
|
|
<p>Aún no tienes proyectos asignados.</p>
|
|
</div>
|
|
{{ end }}
|
|
{{ end }}
|
|
|
|
</div>
|
|
|
|
{{ if .isPartner }}
|
|
<!-- ─── Sección Partner: Recursos y Comunicados ─────────────────────────────── -->
|
|
<div x-data="partnerHub()" x-init="init()" class="mt-10">
|
|
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<span class="w-8 h-8 rounded-lg bg-[#8eb02f] text-white flex items-center justify-center text-sm font-bold">P</span>
|
|
<div>
|
|
<h2 class="text-base font-bold text-slate-800">Zona Partner</h2>
|
|
<p class="text-xs text-slate-400">Recursos y comunicados exclusivos para ti</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-slate-200 mb-5 gap-0">
|
|
<button @click="tab='comunicados'" class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
|
:class="tab==='comunicados' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
|
📢 Comunicados
|
|
<span x-show="comunicados.length > 0" class="ml-1 bg-slate-100 text-slate-500 text-xs px-1.5 py-0.5 rounded-full" x-text="comunicados.length"></span>
|
|
</button>
|
|
<button @click="tab='recursos'" class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
|
:class="tab==='recursos' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
|
|
📁 Recursos
|
|
<span x-show="recursos.length > 0" class="ml-1 bg-slate-100 text-slate-500 text-xs px-1.5 py-0.5 rounded-full" x-text="recursos.length"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Comunicados -->
|
|
<div x-show="tab==='comunicados'" x-cloak>
|
|
<div class="space-y-4">
|
|
<template x-for="com in comunicados" :key="com.ID">
|
|
<div class="bg-white border border-slate-200 rounded-xl p-5">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="flex-1">
|
|
<h3 class="font-semibold text-slate-800 text-sm mb-1" x-text="com.titulo"></h3>
|
|
<p class="text-sm text-slate-600 whitespace-pre-line leading-relaxed" x-text="com.contenido"></p>
|
|
<p class="text-xs text-slate-400 mt-2" x-text="formatDate(com.CreatedAt)"></p>
|
|
</div>
|
|
<template x-if="com.archivo">
|
|
<a :href="`/portal/partner-comunicados/${com.ID}/download`" target="_blank"
|
|
class="flex-shrink-0 inline-flex items-center gap-1.5 text-xs text-[#8eb02f] hover:text-[#6d8c24] border border-[#8eb02f] px-2.5 py-1.5 rounded-lg hover:bg-[#f6ffe0] transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
|
Adjunto
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<p x-show="comunicados.length === 0 && !loading" class="text-slate-400 text-sm text-center py-10">Sin comunicados por ahora.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recursos -->
|
|
<div x-show="tab==='recursos'" x-cloak>
|
|
<!-- Filtro por categoría -->
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
<template x-for="cat in ['todos','general','tecnico','comercial','marketing']" :key="cat">
|
|
<button @click="filtro=cat" class="text-xs px-3 py-1 rounded-full border font-medium transition-colors capitalize"
|
|
:class="filtro===cat ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'border-slate-200 text-slate-500 hover:border-slate-400'" x-text="cat">
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<template x-for="r in recursosFiltrados" :key="r.ID">
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 flex flex-col gap-2">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium capitalize" :class="catClass(r.categoria)" x-text="r.categoria"></span>
|
|
</div>
|
|
<h4 class="font-semibold text-slate-800 text-sm" x-text="r.nombre"></h4>
|
|
<p class="text-xs text-slate-500 mt-0.5 line-clamp-2" x-text="r.descripcion"></p>
|
|
</div>
|
|
</div>
|
|
<template x-if="r.archivo">
|
|
<a :href="`/portal/partner-recursos/${r.ID}/download`" target="_blank"
|
|
class="mt-auto inline-flex items-center gap-2 text-xs font-medium text-white bg-[#8eb02f] hover:bg-[#6d8c24] px-3 py-1.5 rounded-lg transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
|
Descargar
|
|
</a>
|
|
</template>
|
|
<template x-if="r.enlace">
|
|
<a :href="r.enlace" target="_blank" rel="noopener noreferrer"
|
|
class="inline-flex items-center gap-2 text-xs font-medium text-[#8eb02f] border border-[#8eb02f] hover:bg-[#f0f6e4] px-3 py-1.5 rounded-lg transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
|
Ver enlace
|
|
</a>
|
|
</template>
|
|
<template x-if="!r.archivo && !r.enlace">
|
|
<span class="text-xs text-slate-400 italic">Sin archivo adjunto</span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<p x-show="recursosFiltrados.length === 0 && !loading" class="sm:col-span-2 lg:col-span-3 text-slate-400 text-sm text-center py-10">Sin recursos disponibles.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
|
|
<script>
|
|
function partnerHub() {
|
|
return {
|
|
tab: 'comunicados',
|
|
filtro: 'todos',
|
|
recursos: [],
|
|
comunicados: [],
|
|
loading: false,
|
|
|
|
async init() {
|
|
this.loading = true;
|
|
try {
|
|
const r = await axios.get('/portal/partner-recursos');
|
|
this.recursos = r.data.recursos || [];
|
|
this.comunicados = r.data.comunicados || [];
|
|
} catch(e) {} finally { this.loading = false; }
|
|
},
|
|
|
|
get recursosFiltrados() {
|
|
if (this.filtro === 'todos') return this.recursos;
|
|
return this.recursos.filter(r => r.categoria === this.filtro);
|
|
},
|
|
|
|
catClass(cat) {
|
|
return {
|
|
general: 'bg-slate-100 text-slate-600',
|
|
tecnico: 'bg-blue-100 text-blue-700',
|
|
comercial: 'bg-yellow-100 text-yellow-700',
|
|
marketing: 'bg-pink-100 text-pink-700',
|
|
}[cat] || 'bg-slate-100 text-slate-600';
|
|
},
|
|
|
|
formatDate(d) {
|
|
if (!d) return '';
|
|
return new Date(d).toLocaleDateString('es-CO', { day:'2-digit', month:'short', year:'numeric' });
|
|
},
|
|
}
|
|
}
|
|
</script>
|