This commit is contained in:
Lizandro Guarnizo
2026-05-14 23:03:07 -05:00
parent 2377371b2c
commit e02a4f2141
9 changed files with 1622 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
{{template "layouts/main" .}}
<div x-data="facturasApp()" x-init="init()" class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Facturas</h1>
<p class="text-sm text-slate-500 mt-1">Gestión de facturas por cliente y proyecto</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
Nueva factura
</button>
</div>
<div class="mb-4">
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar por número, cliente..." class="input-field w-full max-w-sm">
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 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-4 py-3 text-left">#</th>
<th class="px-4 py-3 text-left">Cliente</th>
<th class="px-4 py-3 text-left">Descripción</th>
<th class="px-4 py-3 text-left">Monto</th>
<th class="px-4 py-3 text-left">Estado</th>
<th class="px-4 py-3 text-left">Vencimiento</th>
<th class="px-4 py-3 text-left">Visible</th>
<th class="px-4 py-3 text-left">PDF</th>
<th class="px-4 py-3 text-left">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-if="loading">
<tr><td colspan="9" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-for="f in items" :key="f.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono text-xs text-slate-500" x-text="f.numero||`#${f.ID}`"></td>
<td class="px-4 py-3 text-slate-700" x-text="f.cliente?.razon_social||f.cliente?.nombre||'-'"></td>
<td class="px-4 py-3 text-slate-600 max-w-xs truncate" x-text="f.descripcion"></td>
<td class="px-4 py-3 font-semibold text-slate-800" x-text="`${f.moneda} ${Number(f.monto).toLocaleString('es-CO')}`"></td>
<td class="px-4 py-3">
<span class="badge" :class="estadoBadge(f.estado)" x-text="f.estado"></span>
</td>
<td class="px-4 py-3 text-xs text-slate-500" x-text="formatDate(f.fecha_vencimiento)"></td>
<td class="px-4 py-3">
<span :class="f.visible ? 'text-green-500' : 'text-slate-300'">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</span>
</td>
<td class="px-4 py-3 flex items-center gap-1">
<template x-if="f.archivo">
<a :href="`/app/facturas/${f.ID}/download`" class="btn-icon text-blue-500" title="Descargar">
<svg class="w-4 h-4" 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>
</a>
</template>
<button @click="openUpload(f)" class="btn-icon text-slate-500" title="Subir PDF">
<svg class="w-4 h-4" 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-8l4-4m0 0l4 4m-4-4v12"/></svg>
</button>
</td>
<td class="px-4 py-3 flex items-center gap-2">
<button @click="openEdit(f)" class="btn-icon text-yellow-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="confirmDelete(f)" class="btn-icon text-red-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
<span>Total: <strong x-text="total"></strong></span>
<div class="flex gap-1">
<button @click="page--;load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
<button @click="page++;load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
</div>
</div>
<!-- Modal crear/editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 max-h-screen overflow-y-auto">
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar factura' : 'Nueva factura'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="label">Cliente</label>
<select x-model.number="form.cliente_id" class="input-field w-full" required>
<option value="">Seleccionar...</option>
<template x-for="c in clientes" :key="c.ID">
<option :value="c.ID" x-text="c.razon_social||c.nombre"></option>
</template>
</select>
</div>
<div class="col-span-2">
<label class="label">Proyecto (opcional)</label>
<select x-model.number="form.proyecto_id" class="input-field w-full">
<option value="">Sin proyecto</option>
<template x-for="p in proyectos" :key="p.ID">
<option :value="p.ID" x-text="p.nombre"></option>
</template>
</select>
</div>
<div><label class="label">N° Factura</label><input x-model="form.numero" class="input-field w-full" placeholder="FAC-001"></div>
<div>
<label class="label">Moneda</label>
<select x-model="form.moneda" class="input-field w-full">
<option value="COP">COP</option>
<option value="USD">USD</option>
<option value="MXN">MXN</option>
<option value="EUR">EUR</option>
</select>
</div>
<div><label class="label">Monto</label><input x-model.number="form.monto" type="number" step="0.01" class="input-field w-full" required></div>
<div>
<label class="label">Estado</label>
<select x-model="form.estado" class="input-field w-full">
<option value="pendiente">Pendiente</option>
<option value="pagada">Pagada</option>
<option value="vencida">Vencida</option>
<option value="cancelada">Cancelada</option>
</select>
</div>
<div><label class="label">Fecha emisión</label><input x-model="form.fecha_emision" type="date" class="input-field w-full"></div>
<div><label class="label">Fecha vencimiento</label><input x-model="form.fecha_vencimiento" type="date" class="input-field w-full"></div>
<div class="col-span-2"><label class="label">Descripción</label><textarea x-model="form.descripcion" class="input-field w-full" rows="2"></textarea></div>
<div class="col-span-2"><label class="label">Notas internas</label><textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea></div>
<div class="col-span-2 flex items-center gap-2">
<input type="checkbox" x-model="form.visible" id="fv" class="w-4 h-4">
<label for="fv" class="text-sm text-slate-600">Visible al cliente en el portal</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-3 mt-5">
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
</div>
</form>
</div>
</div>
<!-- Modal subir PDF -->
<div x-show="showUploadModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showUploadModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Subir PDF de factura</h2>
<input type="file" accept=".pdf" @change="pdfFile=$event.target.files[0]" class="input-field w-full mb-4">
<p x-show="uploadError" x-text="uploadError" class="text-red-500 text-sm mb-3"></p>
<div class="flex justify-end gap-3">
<button @click="showUploadModal=false" class="btn-secondary">Cancelar</button>
<button @click="doUpload()" :disabled="!pdfFile||saving" class="btn-primary" x-text="saving?'Subiendo...':'Subir'"></button>
</div>
</div>
</div>
<!-- Modal eliminar -->
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-2">¿Eliminar factura?</h2>
<p class="text-slate-500 text-sm mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-end gap-3">
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
</div>
</div>
</div>
</div>
<script>
function facturasApp() {
return {
items:[], clientes:[], proyectos:[], total:0, totalPages:1, page:1, search:'',
loading:false, saving:false, showModal:false, showDelete:false, showUploadModal:false,
editId:null, deleteId:null, uploadId:null, error:'', uploadError:'', pdfFile:null,
form:{ cliente_id:'', proyecto_id:'', numero:'', descripcion:'', monto:0, moneda:'COP', estado:'pendiente', fecha_emision:'', fecha_vencimiento:'', visible:true, notas:'' },
async init(){ await this.load(); },
async load(){
this.loading=true;
try {
const r=await axios.get(`/app/loadfacturas?page=${this.page}&search=${encodeURIComponent(this.search)}`);
this.items=r.data.items||[];
this.total=r.data.total;
this.totalPages=r.data.totalPages;
this.clientes=r.data.clientes||[];
this.proyectos=r.data.proyectos||[];
} finally{ this.loading=false; }
},
openCreate(){
this.editId=null; this.error='';
this.form={cliente_id:'',proyecto_id:'',numero:'',descripcion:'',monto:0,moneda:'COP',estado:'pendiente',fecha_emision:'',fecha_vencimiento:'',visible:true,notas:''};
this.showModal=true;
},
openEdit(f){
this.editId=f.ID; this.error='';
const fv=f.fecha_vencimiento?f.fecha_vencimiento.substring(0,10):'';
const fe=f.fecha_emision?f.fecha_emision.substring(0,10):'';
this.form={cliente_id:f.cliente_id,proyecto_id:f.proyecto_id||'',numero:f.numero||'',descripcion:f.descripcion||'',monto:f.monto,moneda:f.moneda||'COP',estado:f.estado,fecha_emision:fe,fecha_vencimiento:fv,visible:f.visible,notas:f.notas||''};
this.showModal=true;
},
async save(){
this.saving=true; this.error='';
const payload={...this.form};
if(!payload.proyecto_id) payload.proyecto_id=null;
try {
if(this.editId) await axios.put(`/app/facturas/${this.editId}`, payload);
else await axios.post('/app/facturas', payload);
this.showModal=false; await this.load();
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
finally{ this.saving=false; }
},
confirmDelete(f){ this.deleteId=f.ID; this.showDelete=true; },
async doDelete(){
this.saving=true;
try{ await axios.delete(`/app/facturas/${this.deleteId}`); this.showDelete=false; await this.load(); }
finally{ this.saving=false; }
},
openUpload(f){ this.uploadId=f.ID; this.pdfFile=null; this.uploadError=''; this.showUploadModal=true; },
async doUpload(){
if(!this.pdfFile) return;
this.saving=true; this.uploadError='';
try {
const fd=new FormData(); fd.append('pdf', this.pdfFile);
await axios.post(`/app/facturas/${this.uploadId}/upload-pdf`, fd, {headers:{'Content-Type':'multipart/form-data'}});
this.showUploadModal=false; await this.load();
} catch(e){ this.uploadError=e.response?.data?.error||'Error al subir'; }
finally{ this.saving=false; }
},
estadoBadge(e){ return {pendiente:'badge-yellow',pagada:'badge-green',vencida:'badge-red',cancelada:'badge-slate'}[e]||'badge-slate'; },
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
}
}
</script>
<style>
.btn-primary{background:#8eb02f;color:#fff;padding:.5rem 1rem;border-radius:.5rem;font-weight:600;font-size:.875rem;}
.btn-primary:hover{background:#6d8c24;}
.btn-secondary{background:#f1f5f9;color:#475569;padding:.5rem 1rem;border-radius:.5rem;font-weight:500;font-size:.875rem;border:1px solid #e2e8f0;}
.btn-danger{background:#ef4444;color:#fff;padding:.5rem 1rem;border-radius:.5rem;font-weight:600;font-size:.875rem;}
.btn-icon{display:inline-flex;align-items:center;padding:.25rem;border-radius:.375rem;}
.btn-icon:hover{background:#f1f5f9;}
.input-field{border:1px solid #e2e8f0;border-radius:.5rem;padding:.5rem .75rem;font-size:.875rem;outline:none;}
.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;text-transform:capitalize;}
.badge-green{background:#dcfce7;color:#15803d;}
.badge-yellow{background:#fef9c3;color:#854d0e;}
.badge-slate{background:#f1f5f9;color:#475569;}
.badge-red{background:#fee2e2;color:#991b1b;}
</style>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal de Clientes · U-site</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.6.7/dist/axios.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #f8fafc; }
[x-cloak] { display: none !important; }
:root { --brand: #8eb02f; --brand-dark: #6d8c24; }
</style>
</head>
<body class="min-h-screen flex flex-col">
<!-- Navbar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<!-- Logo -->
<a href="/portal/dashboard" class="flex items-center gap-2 font-bold text-slate-800 text-lg">
<span class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold" style="background:#8eb02f">U</span>
<span>Portal de Clientes</span>
</a>
<!-- User -->
<div class="flex items-center gap-4">
<span class="text-sm text-slate-600 hidden sm:block">
{{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }}
</span>
<a href="/portal/logout" class="text-sm text-red-500 hover:text-red-700 font-medium transition-colors">
Cerrar sesión
</a>
</div>
</div>
</header>
<!-- Content -->
<main class="flex-1 max-w-6xl mx-auto w-full px-4 sm:px-6 py-8">
{{embed}}
</main>
<!-- Footer -->
<footer class="border-t border-slate-200 py-4 text-center text-xs text-slate-400">
Powered by U-site &mdash; &copy; 2025
</footer>
</body>
</html>
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acceso al Portal · U-site</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #f1f5f9; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center">
{{embed}}
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<!-- 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>
<!-- 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.RazonSocial }}{{ .Cliente.RazonSocial }}{{ 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>
+43
View File
@@ -0,0 +1,43 @@
<!-- Portal Login -->
<div class="w-full max-w-md mx-auto">
<div class="text-center mb-8">
<span class="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4" style="background:#8eb02f">U</span>
<h1 class="text-2xl font-bold text-slate-800">Portal de Clientes</h1>
<p class="text-slate-500 text-sm mt-1">Accede a tu espacio de trabajo</p>
</div>
{{ if .error }}
<div class="bg-red-50 border border-red-200 text-red-700 rounded-xl px-4 py-3 mb-4 text-sm">
{{ .error }}
</div>
{{ end }}
<div class="bg-white rounded-2xl shadow-lg border border-slate-200 p-8">
<form method="POST" action="/portal/login">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Email</label>
<input type="email" name="email" required autofocus
class="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-[#8eb02f] transition-colors"
placeholder="tu@email.com">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Contraseña</label>
<input type="password" name="password" required
class="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-[#8eb02f] transition-colors">
</div>
<button type="submit"
class="w-full py-3 rounded-xl font-semibold text-white text-sm transition-colors"
style="background:#8eb02f"
onmouseover="this.style.background='#6d8c24'"
onmouseout="this.style.background='#8eb02f'">
Ingresar
</button>
</div>
</form>
</div>
<p class="text-center text-xs text-slate-400 mt-6">
&copy; 2025 U-site &mdash; Todos los derechos reservados
</p>
</div>
+294
View File
@@ -0,0 +1,294 @@
<!-- Portal Proyecto Detalle -->
<div x-data="portalProyecto()" x-init="init()">
<!-- Header -->
<div class="flex items-center gap-3 mb-6">
<a href="/portal/dashboard" class="text-slate-400 hover:text-slate-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</a>
<span class="w-4 h-4 rounded-full border" style="background:{{ .proyecto.Color }}"></span>
<div>
<h1 class="text-xl font-bold text-slate-800">{{ .proyecto.Nombre }}</h1>
<p class="text-xs text-slate-400">{{ .proyecto.Descripcion }}</p>
</div>
<div class="ml-auto">
<div class="flex items-center gap-2">
<div class="w-32 bg-slate-200 rounded-full h-2">
<div class="h-2 rounded-full" style="background:{{ .proyecto.Color }};width:{{ .proyecto.Progreso }}%"></div>
</div>
<span class="text-sm font-bold text-slate-700">{{ .proyecto.Progreso }}%</span>
</div>
<p class="text-xs text-slate-400 mt-1 text-right capitalize">{{ .proyecto.Estado }}</p>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-200 mb-6 gap-0 overflow-x-auto">
<template x-for="tab in ['Roadmap','Avances','Entregables','Tickets','Facturas']" :key="tab">
<button @click="activeTab=tab" class="px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors"
:class="activeTab===tab ? 'font-semibold border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'"
x-text="tab"></button>
</template>
</div>
<!-- ─── ROADMAP ────────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Roadmap'" x-cloak>
<div class="relative">
<!-- Línea vertical -->
<div class="absolute left-6 top-0 bottom-0 w-0.5 bg-slate-200"></div>
<div class="space-y-0">
{{ range $i, $f := .fases }}
<div class="relative flex items-start gap-4 pb-6">
<!-- Dot -->
<div class="relative z-10 flex-shrink-0">
{{ if eq $f.Estado "completado" }}
<div class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-sm" style="background:#8eb02f">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
</div>
{{ else if eq $f.Estado "en_progreso" }}
<div class="w-12 h-12 rounded-full flex items-center justify-center font-bold text-sm border-2 border-[#8eb02f] text-[#8eb02f] bg-white">
{{ add $i 1 }}
</div>
{{ else }}
<div class="w-12 h-12 rounded-full flex items-center justify-center font-bold text-sm border-2 border-slate-200 text-slate-400 bg-white">
{{ add $i 1 }}
</div>
{{ end }}
</div>
<!-- Contenido -->
<div class="flex-1 bg-white rounded-xl border border-slate-200 p-4 mt-1">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-slate-800 text-sm">{{ $f.Nombre }}</h3>
<span class="text-xs px-2 py-0.5 rounded-full capitalize font-medium
{{ if eq $f.Estado "completado" }}bg-green-100 text-green-700
{{ else if eq $f.Estado "en_progreso" }}bg-yellow-100 text-yellow-700
{{ else if eq $f.Estado "bloqueado" }}bg-red-100 text-red-700
{{ else }}bg-slate-100 text-slate-500{{ end }}">
{{ $f.Estado }}
</span>
</div>
<p class="text-xs text-slate-500">{{ $f.Descripcion }}</p>
</div>
{{ if $f.FechaEstimada }}
<span class="text-xs text-slate-400 flex-shrink-0 ml-3">
Est: {{ $f.FechaEstimada.Format "02 Jan 2006" }}
</span>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ if not .fases }}
<p class="text-slate-400 text-sm text-center py-10">El roadmap aún no está disponible.</p>
{{ end }}
</div>
</div>
</div>
<!-- ─── AVANCES ───────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Avances'" x-cloak>
<div class="space-y-4">
{{ range .avances }}
<div class="bg-white rounded-xl border border-slate-200 p-5">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-xs font-bold"
style="background:#8eb02f;color:#fff">
{{ if eq .Tipo "milestone" }}🎯{{ else if eq .Tipo "alerta" }}⚠️{{ else }}✓{{ end }}
</div>
<div>
<h3 class="font-semibold text-slate-800 text-sm">{{ .Titulo }}</h3>
<p class="text-sm text-slate-600 mt-1">{{ .Contenido }}</p>
<p class="text-xs text-slate-400 mt-2">{{ .CreatedAt.Format "02 Jan 2006" }}</p>
</div>
</div>
</div>
{{ end }}
{{ if not .avances }}
<p class="text-slate-400 text-sm text-center py-10">Sin avances publicados aún.</p>
{{ end }}
</div>
</div>
<!-- ─── ENTREGABLES ───────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Entregables'" x-cloak>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{{ range .entregables }}
<div class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4">
<div class="w-10 h-10 rounded-xl flex items-center justify-center bg-slate-100 flex-shrink-0">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-slate-800 truncate">{{ .Nombre }}</p>
<p class="text-xs text-slate-500">v{{ .Version }} &middot; {{ .OriginalName }}</p>
</div>
<a href="/portal/entregables/{{ .ID }}/download" class="flex-shrink-0 text-[#8eb02f] hover:text-[#6d8c24]">
<svg class="w-5 h-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>
</a>
</div>
{{ end }}
{{ if not .entregables }}
<p class="col-span-2 text-slate-400 text-sm text-center py-10">Sin entregables disponibles.</p>
{{ end }}
</div>
</div>
<!-- ─── TICKETS ───────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Tickets'" x-cloak>
<!-- Crear ticket -->
<div class="bg-white rounded-xl border border-slate-200 p-5 mb-6">
<h3 class="font-semibold text-slate-700 mb-3 text-sm">Abrir nuevo ticket</h3>
<form @submit.prevent="crearTicket()">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div class="sm:col-span-2"><input x-model="ticketForm.titulo" type="text" placeholder="Título del ticket" class="input-field w-full" required></div>
<div>
<select x-model="ticketForm.prioridad" class="input-field w-full">
<option value="baja">Prioridad baja</option>
<option value="media" selected>Prioridad media</option>
<option value="alta">Prioridad alta</option>
</select>
</div>
<div class="sm:col-span-2"><textarea x-model="ticketForm.descripcion" placeholder="Describe el problema o consulta..." rows="3" class="input-field w-full"></textarea></div>
</div>
<button type="submit" :disabled="creandoTicket" class="btn-primary text-sm" x-text="creandoTicket ? 'Enviando...' : 'Enviar ticket'"></button>
</form>
</div>
<!-- Lista de tickets -->
<div class="space-y-3">
<template x-for="t in tickets" :key="t.ID">
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div class="p-4 flex items-start justify-between cursor-pointer" @click="t._open=!t._open">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="badge" :class="ticketBadge(t.estado)" x-text="t.estado"></span>
<span class="badge" :class="prioBadge(t.prioridad)" x-text="t.prioridad"></span>
</div>
<h4 class="font-semibold text-slate-800 text-sm" x-text="t.titulo"></h4>
<p class="text-xs text-slate-400" x-text="formatDate(t.CreatedAt)"></p>
</div>
<svg class="w-4 h-4 text-slate-400 transition-transform" :class="t._open ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>
</div>
<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>
<div class="space-y-2 mt-3">
<template x-for="m in (t.mensajes||[])" :key="m.ID">
<div class="flex" :class="m.es_admin ? 'justify-start' : 'justify-end'">
<div class="rounded-xl px-3 py-2 text-sm max-w-xs"
:class="m.es_admin ? 'bg-slate-100 text-slate-700' : 'text-white'" :style="m.es_admin ? '' : 'background:#8eb02f'">
<div class="text-xs font-semibold mb-0.5" x-text="m.autor_nombre" :style="m.es_admin ? 'color:#64748b' : 'color:rgba(255,255,255,.8)'"></div>
<span x-text="m.contenido"></span>
</div>
</div>
</template>
</div>
<template x-if="t.estado !== 'cerrado' && t.estado !== 'resuelto'">
<div class="flex gap-2 mt-3">
<input x-model="t._reply" type="text" placeholder="Responder..." class="input-field flex-1 text-sm">
<button @click="responderTicket(t)" class="btn-primary text-xs">Enviar</button>
</div>
</template>
</div>
</div>
</template>
<p x-show="tickets.length===0 && !loadingTickets" class="text-slate-400 text-sm text-center py-6">No tienes tickets abiertos.</p>
</div>
</div>
<!-- ─── FACTURAS ──────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Facturas'" x-cloak>
<div class="space-y-3">
{{ range .facturas }}
<div class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-sm text-slate-800">{{ if .Numero }}{{ .Numero }}{{ else }}Factura #{{ .ID }}{{ end }}</span>
<span class="text-xs px-2 py-0.5 rounded-full font-semibold capitalize
{{ if eq .Estado "pagada" }}bg-green-100 text-green-700
{{ else if eq .Estado "vencida" }}bg-red-100 text-red-700
{{ else if eq .Estado "cancelada" }}bg-slate-100 text-slate-500
{{ else }}bg-yellow-100 text-yellow-700{{ end }}">{{ .Estado }}</span>
</div>
<p class="text-xs text-slate-500">{{ .Descripcion }}</p>
<p class="text-sm font-bold text-slate-700 mt-1">{{ .Moneda }} {{ .Monto }}</p>
{{ if .FechaVencimiento }}<p class="text-xs text-slate-400">Vence: {{ .FechaVencimiento.Format "02 Jan 2006" }}</p>{{ end }}
</div>
{{ if .Archivo }}
<a href="/portal/facturas/{{ .ID }}/download" class="text-[#8eb02f] hover:text-[#6d8c24]">
<svg class="w-5 h-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>
</a>
{{ end }}
</div>
{{ end }}
{{ if not .facturas }}
<p class="text-slate-400 text-sm text-center py-10">Sin facturas disponibles.</p>
{{ end }}
</div>
</div>
</div>
<script>
const PROYECTO_SLUG = '{{ .proyecto.Slug }}';
function portalProyecto() {
return {
activeTab: 'Roadmap',
tickets: [],
loadingTickets: false,
creandoTicket: false,
ticketForm: { titulo:'', descripcion:'', prioridad:'media' },
async init() {
await this.loadTickets();
},
async loadTickets() {
this.loadingTickets = true;
try {
const r = await axios.get(`/portal/api/proyecto/${PROYECTO_SLUG}`);
this.tickets = (r.data.tickets||[]).map(t => ({...t, _open:false, _reply:''}));
} catch(e){} finally { this.loadingTickets=false; }
},
async crearTicket() {
this.creandoTicket=true;
try {
const r = await axios.post('/portal/tickets', { ...this.ticketForm, proyecto_slug: PROYECTO_SLUG });
this.tickets.unshift({...r.data, _open:false, _reply:''});
this.ticketForm={titulo:'',descripcion:'',prioridad:'media'};
} catch(e) {
alert(e.response?.data?.error || 'Error al crear ticket');
} finally { this.creandoTicket=false; }
},
async responderTicket(t) {
if(!t._reply?.trim()) return;
try {
const r = await axios.post(`/portal/tickets/${t.ID}/mensaje`, {contenido: t._reply});
if(!t.mensajes) t.mensajes=[];
t.mensajes.push(r.data);
t._reply='';
} catch(e) { alert(e.response?.data?.error||'Error'); }
},
ticketBadge(e){ return {abierto:'badge-red',en_progreso:'badge-yellow',resuelto:'badge-green',cerrado:'badge-slate'}[e]||'badge-slate'; },
prioBadge(p){ return {alta:'badge-red',media:'badge-yellow',baja:'badge-green'}[p]||'badge-slate'; },
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
}
}
</script>
<style>
.btn-primary{background:#8eb02f;color:#fff;padding:.5rem 1rem;border-radius:.5rem;font-weight:600;font-size:.875rem;}
.btn-primary:hover{background:#6d8c24;}
.input-field{border:1px solid #e2e8f0;border-radius:.5rem;padding:.5rem .75rem;font-size:.875rem;outline:none;}
.input-field:focus{border-color:#8eb02f;}
.badge{display:inline-block;padding:.15rem .6rem;border-radius:9999px;font-size:.7rem;font-weight:600;text-transform:capitalize;}
.badge-green{background:#dcfce7;color:#15803d;}
.badge-yellow{background:#fef9c3;color:#854d0e;}
.badge-slate{background:#f1f5f9;color:#475569;}
.badge-red{background:#fee2e2;color:#991b1b;}
</style>
+225
View File
@@ -0,0 +1,225 @@
{{template "layouts/main" .}}
<div x-data="portalUsuariosApp()" x-init="init()" class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Usuarios del Portal</h1>
<p class="text-sm text-slate-500 mt-1">Accesos del portal de clientes</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
Nuevo usuario
</button>
</div>
<!-- Tabla -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 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-4 py-3 text-left">Nombre</th>
<th class="px-4 py-3 text-left">Email</th>
<th class="px-4 py-3 text-left">Rol</th>
<th class="px-4 py-3 text-left">Cliente / Accesos</th>
<th class="px-4 py-3 text-left">Estado</th>
<th class="px-4 py-3 text-left">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-if="loading">
<tr><td colspan="6" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-for="u in items" :key="u.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-800" x-text="u.nombre"></td>
<td class="px-4 py-3 text-slate-600" x-text="u.email"></td>
<td class="px-4 py-3">
<span class="badge" :class="u.rol==='partner' ? 'badge-blue' : 'badge-green'" x-text="u.rol"></span>
</td>
<td class="px-4 py-3 text-slate-500 text-xs">
<template x-if="u.rol==='cliente' && u.cliente">
<span x-text="u.cliente?.razon_social || u.cliente?.nombre"></span>
</template>
<template x-if="u.rol==='partner'">
<div class="flex flex-wrap gap-1">
<template x-for="acc in (u.portal_accesos||[])" :key="acc.ID">
<span class="bg-slate-100 px-2 py-0.5 rounded text-xs flex items-center gap-1">
<span x-text="acc.cliente?.razon_social || acc.cliente?.nombre || acc.cliente_id"></span>
<button @click="removeAcceso(u, acc.cliente_id)" class="text-red-400 hover:text-red-600 ml-1">&times;</button>
</span>
</template>
<button @click="openAddAcceso(u)" class="text-primary text-xs hover:underline">+ Agregar</button>
</div>
</template>
</td>
<td class="px-4 py-3">
<span :class="u.activo ? 'badge-green' : 'badge-slate'" class="badge" x-text="u.activo ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="px-4 py-3 flex gap-2">
<button @click="openEdit(u)" class="btn-icon text-yellow-500" title="Editar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="confirmDelete(u)" class="btn-icon text-red-500" title="Eliminar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Modal crear/editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6">
<h2 class="text-lg font-bold mb-4" x-text="editId ? 'Editar usuario' : 'Nuevo usuario'"></h2>
<form @submit.prevent="save()">
<div class="space-y-3">
<div><label class="label">Nombre</label><input x-model="form.nombre" class="input-field w-full" required></div>
<div><label class="label">Email</label><input x-model="form.email" type="email" class="input-field w-full" required></div>
<div>
<label class="label">Contraseña <span class="normal-case font-normal text-slate-400" x-show="editId">(dejar vacío para no cambiar)</span></label>
<input x-model="form.password" type="password" class="input-field w-full" :required="!editId">
</div>
<div>
<label class="label">Rol</label>
<select x-model="form.rol" class="input-field w-full">
<option value="cliente">Cliente</option>
<option value="partner">Partner</option>
</select>
</div>
<div x-show="form.rol==='cliente'">
<label class="label">Cliente asignado</label>
<select x-model.number="form.cliente_id" class="input-field w-full">
<option value="">Sin asignar</option>
<template x-for="c in clientes" :key="c.ID">
<option :value="c.ID" x-text="c.razon_social || c.nombre"></option>
</template>
</select>
</div>
<div>
<label class="label">Activo</label>
<input type="checkbox" x-model="form.activo" class="w-4 h-4">
</div>
<div><label class="label">Notas</label><textarea x-model="form.notas" class="input-field w-full" rows="2"></textarea></div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<div class="flex justify-end gap-3 mt-5">
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
</div>
</form>
</div>
</div>
<!-- Modal agregar acceso (partner) -->
<div x-show="showAccesoModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showAccesoModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Agregar acceso a cliente</h2>
<label class="label">Cliente</label>
<select x-model.number="accesoClienteId" class="input-field w-full mb-4">
<option value="">Seleccionar...</option>
<template x-for="c in clientes" :key="c.ID">
<option :value="c.ID" x-text="c.razon_social || c.nombre"></option>
</template>
</select>
<div class="flex justify-end gap-3">
<button @click="showAccesoModal=false" class="btn-secondary">Cancelar</button>
<button @click="addAcceso()" :disabled="!accesoClienteId" class="btn-primary">Agregar</button>
</div>
</div>
</div>
<!-- Modal eliminar -->
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-2">¿Eliminar usuario?</h2>
<p class="text-slate-500 text-sm mb-5">Se revocará el acceso al portal.</p>
<div class="flex justify-end gap-3">
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving?'Eliminando...':'Eliminar'"></button>
</div>
</div>
</div>
</div>
<script>
function portalUsuariosApp() {
return {
items: [], clientes: [], loading: false, saving: false,
showModal: false, showDelete: false, showAccesoModal: false,
editId: null, deleteId: null, error: '',
accesoUserId: null, accesoClienteId: '',
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'' },
async init() { await this.load(); },
async load() {
this.loading = true;
try {
const r = await axios.get('/app/loadportalusuarios');
this.items = r.data.items || [];
this.clientes = r.data.clientes || [];
} finally { this.loading = false; }
},
openCreate() {
this.editId=null; this.error='';
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:''};
this.showModal=true;
},
openEdit(u) {
this.editId=u.ID; this.error='';
this.form={nombre:u.nombre,email:u.email,password:'',rol:u.rol,cliente_id:u.cliente_id||'',activo:u.activo,notas:u.notas||''};
this.showModal=true;
},
async save() {
this.saving=true; this.error='';
const payload={...this.form};
if(payload.cliente_id==='') payload.cliente_id=null;
try {
if(this.editId) await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
else await axios.post('/app/portal-usuarios', payload);
this.showModal=false; await this.load();
} catch(e) { this.error=e.response?.data?.error||'Error al guardar'; }
finally { this.saving=false; }
},
confirmDelete(u) { this.deleteId=u.ID; this.showDelete=true; },
async doDelete() {
this.saving=true;
try { await axios.delete(`/app/portal-usuarios/${this.deleteId}`); this.showDelete=false; await this.load(); }
finally { this.saving=false; }
},
openAddAcceso(u) { this.accesoUserId=u.ID; this.accesoClienteId=''; this.showAccesoModal=true; },
async addAcceso() {
await axios.post(`/app/portal-usuarios/${this.accesoUserId}/acceso`, {cliente_id: this.accesoClienteId});
this.showAccesoModal=false; await this.load();
},
async removeAcceso(u, clienteId) {
if(!confirm('¿Quitar acceso a este cliente?')) return;
await axios.delete(`/app/portal-usuarios/${u.ID}/acceso/${clienteId}`);
await this.load();
},
}
}
</script>
<style>
.btn-primary{background:#8eb02f;color:#fff;padding:.5rem 1rem;border-radius:.5rem;font-weight:600;font-size:.875rem;}
.btn-primary:hover{background:#6d8c24;}
.btn-secondary{background:#f1f5f9;color:#475569;padding:.5rem 1rem;border-radius:.5rem;font-weight:500;font-size:.875rem;border:1px solid #e2e8f0;}
.btn-danger{background:#ef4444;color:#fff;padding:.5rem 1rem;border-radius:.5rem;font-weight:600;font-size:.875rem;}
.btn-icon{display:inline-flex;align-items:center;padding:.25rem;border-radius:.375rem;}
.btn-icon:hover{background:#f1f5f9;}
.input-field{border:1px solid #e2e8f0;border-radius:.5rem;padding:.5rem .75rem;font-size:.875rem;outline:none;}
.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;text-transform:capitalize;}
.badge-green{background:#dcfce7;color:#15803d;}
.badge-blue{background:#dbeafe;color:#1d4ed8;}
.badge-slate{background:#f1f5f9;color:#475569;}
.text-primary{color:#8eb02f;}
</style>
+399
View File
@@ -0,0 +1,399 @@
{{template "layouts/main" .}}
<div x-data="proyectoDetalle({{ .proyecto.ID }}, '{{ .proyecto.Slug }}')" x-init="init()" class="p-6">
<!-- Header del proyecto -->
<div class="flex items-center gap-3 mb-6">
<a href="/app/proyectos" class="text-slate-400 hover:text-slate-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</a>
<span class="w-4 h-4 rounded-full border" :style="`background:{{ .proyecto.Color }}`"></span>
<div>
<h1 class="text-xl font-bold text-slate-800">{{ .proyecto.Nombre }}</h1>
<p class="text-xs text-slate-400">{{ if .proyecto.Cliente }}{{ .proyecto.Cliente.RazonSocial }}{{ end }} &middot; slug: <code>{{ .proyecto.Slug }}</code></p>
</div>
<div class="ml-auto flex items-center gap-3">
<span class="badge" :class="`badge-${estadoColor('{{ .proyecto.Estado }}')}`">{{ .proyecto.Estado }}</span>
<span class="text-sm text-slate-500">
<strong>{{ .proyecto.Progreso }}%</strong> completado
</span>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-200 mb-6 gap-0">
<template x-for="tab in ['Fases','Avances','Entregables','Tickets']" :key="tab">
<button @click="activeTab=tab" :class="activeTab===tab ? 'border-b-2 font-semibold text-primary' : 'text-slate-500'" class="px-4 py-2 text-sm border-b-2 border-transparent -mb-px transition-colors" x-text="tab" :style="activeTab===tab ? 'border-color:#8eb02f;color:#8eb02f' : ''"></button>
</template>
</div>
<!-- ─── Tab Fases ─────────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Fases'" x-cloak>
<div class="flex justify-between mb-4">
<h2 class="font-semibold text-slate-700">Fases del proyecto</h2>
<div class="flex gap-2">
<button @click="applyTemplate()" class="btn-secondary text-xs">Aplicar plantilla</button>
<button @click="openFaseModal()" class="btn-primary text-xs">+ Agregar fase</button>
</div>
</div>
<div class="space-y-2">
<template x-for="f in fases" :key="f.ID">
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-start gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-bold text-slate-400" x-text="`#${f.orden}`"></span>
<span class="font-semibold text-slate-800 text-sm" x-text="f.nombre"></span>
<span class="badge" :class="faseBadgeClass(f.estado)" x-text="f.estado"></span>
</div>
<p class="text-xs text-slate-500" x-text="f.descripcion"></p>
<div class="flex gap-4 mt-1 text-xs text-slate-400">
<span x-show="f.fecha_estimada">Est: <span x-text="formatDate(f.fecha_estimada)"></span></span>
<span x-show="f.fecha_completado">Completado: <span x-text="formatDate(f.fecha_completado)"></span></span>
</div>
</div>
<div class="flex gap-1">
<button @click="editFase(f)" class="btn-icon text-yellow-500"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg></button>
<button @click="deleteFase(f.ID)" class="btn-icon text-red-500"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
</div>
</div>
</template>
<p x-show="fases.length===0" class="text-slate-400 text-sm text-center py-8">Sin fases. Agrega o aplica la plantilla por defecto.</p>
</div>
</div>
<!-- ─── Tab Avances ───────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Avances'" x-cloak>
<div class="flex justify-between mb-4">
<h2 class="font-semibold text-slate-700">Avances y actualizaciones</h2>
<button @click="openAvanceModal()" class="btn-primary text-xs">+ Publicar avance</button>
</div>
<div class="space-y-3">
<template x-for="av in avances" :key="av.ID">
<div class="bg-white border border-slate-200 rounded-xl p-4">
<div class="flex items-start justify-between">
<div>
<span class="badge" :class="avanceBadgeClass(av.tipo)" x-text="av.tipo"></span>
<h3 class="font-semibold text-slate-800 mt-1" x-text="av.titulo"></h3>
<p class="text-sm text-slate-600 mt-1" x-text="av.contenido"></p>
</div>
<div class="flex gap-1 ml-4">
<button @click="toggleAvanceVisible(av)" :title="av.visible ? 'Ocultar' : 'Publicar'" class="btn-icon" :class="av.visible ? 'text-green-500' : 'text-slate-400'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
<button @click="deleteAvance(av.ID)" class="btn-icon text-red-400"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
</div>
</div>
<p class="text-xs text-slate-400 mt-2" x-text="formatDate(av.CreatedAt)"></p>
</div>
</template>
<p x-show="avances.length===0" class="text-slate-400 text-sm text-center py-8">Sin avances publicados.</p>
</div>
</div>
<!-- ─── Tab Entregables ───────────────────────────────────────────────────── -->
<div x-show="activeTab==='Entregables'" x-cloak>
<div class="flex justify-between mb-4">
<h2 class="font-semibold text-slate-700">Entregables</h2>
<button @click="openEntregableModal()" class="btn-primary text-xs">+ Subir entregable</button>
</div>
<div class="space-y-2">
<template x-for="e in entregables" :key="e.ID">
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-center gap-4">
<div class="flex-1">
<div class="font-semibold text-sm text-slate-800" x-text="e.nombre"></div>
<div class="text-xs text-slate-500" x-text="e.descripcion"></div>
<div class="text-xs text-slate-400 mt-1" x-text="`v${e.version} · ${formatBytes(e.tamanio)} · ${e.original_name}`"></div>
</div>
<div class="flex items-center gap-2">
<button @click="toggleEntregableVisible(e)" :title="e.visible ? 'Visible al cliente' : 'Oculto'" class="btn-icon" :class="e.visible ? 'text-green-500' : 'text-slate-300'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
<a :href="`/app/proyectos/{{ .proyecto.ID }}/entregables/${e.ID}/download`" class="btn-icon text-blue-500">
<svg class="w-4 h-4" 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>
</a>
<button @click="deleteEntregable(e.ID)" class="btn-icon text-red-400"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
</div>
</div>
</template>
<p x-show="entregables.length===0" class="text-slate-400 text-sm text-center py-8">Sin entregables.</p>
</div>
</div>
<!-- ─── Tab Tickets ───────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Tickets'" x-cloak>
<div class="flex justify-between mb-4">
<h2 class="font-semibold text-slate-700">Tickets de soporte</h2>
</div>
<div class="space-y-3">
<template x-for="t in tickets" :key="t.ID">
<div class="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div class="p-4 flex items-start justify-between cursor-pointer" @click="t._open = !t._open">
<div>
<div class="flex 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>
</div>
<h3 class="font-semibold text-sm text-slate-800" x-text="t.titulo"></h3>
<p class="text-xs text-slate-500" x-text="`Por: ${t.autor_nombre} · ${formatDate(t.CreatedAt)}`"></p>
</div>
<div class="flex items-center gap-2">
<select @click.stop @change="cambiarEstado(t, $event.target.value)" class="input-field text-xs py-1">
<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>
</div>
</div>
<!-- Thread -->
<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>
<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 text-slate-700' : 'bg-slate-100 text-slate-700'" class="rounded-xl px-3 py-2 text-sm max-w-sm">
<div class="text-xs font-semibold mb-1" x-text="m.autor_nombre" :style="m.es_admin ? 'color:#8eb02f' : 'color:#64748b'"></div>
<span x-text="m.contenido"></span>
</div>
</div>
</template>
</div>
</template>
<!-- Responder -->
<div class="mt-3 flex gap-2">
<input :x-model="`replyText_${t.ID}`" x-model="t._reply" type="text" placeholder="Escribir respuesta..." class="input-field flex-1 text-sm">
<button @click="responder(t)" class="btn-primary text-xs">Enviar</button>
</div>
</div>
</div>
</template>
<p x-show="tickets.length===0" class="text-slate-400 text-sm text-center py-8">Sin tickets.</p>
</div>
</div>
<!-- ─── Modal Fase ────────────────────────────────────────────────────────── -->
<div x-show="showFaseModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showFaseModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
<h2 class="text-lg font-bold mb-4" x-text="faseEditId ? 'Editar fase' : 'Nueva fase'"></h2>
<form @submit.prevent="saveFase()">
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2"><label class="label">Nombre</label><input x-model="faseForm.nombre" class="input-field w-full" required></div>
<div class="col-span-2"><label class="label">Descripción</label><textarea x-model="faseForm.descripcion" class="input-field w-full" rows="2"></textarea></div>
<div><label class="label">Orden</label><input x-model.number="faseForm.orden" type="number" class="input-field w-full"></div>
<div>
<label class="label">Estado</label>
<select x-model="faseForm.estado" class="input-field w-full">
<option value="pendiente">Pendiente</option>
<option value="en_progreso">En progreso</option>
<option value="completado">Completado</option>
<option value="bloqueado">Bloqueado</option>
</select>
</div>
<div><label class="label">Fecha estimada</label><input x-model="faseForm.fecha_estimada" type="date" class="input-field w-full"></div>
</div>
<div class="flex justify-end gap-3 mt-4">
<button type="button" @click="showFaseModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
</div>
</form>
</div>
</div>
<!-- ─── Modal Avance ──────────────────────────────────────────────────────── -->
<div x-show="showAvanceModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showAvanceModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Publicar avance</h2>
<form @submit.prevent="saveAvance()">
<div class="space-y-3">
<div>
<label class="label">Tipo</label>
<select x-model="avanceForm.tipo" class="input-field w-full">
<option value="update">Actualización</option>
<option value="milestone">Hito</option>
<option value="nota">Nota</option>
<option value="alerta">Alerta</option>
</select>
</div>
<div><label class="label">Título</label><input x-model="avanceForm.titulo" class="input-field w-full" required></div>
<div><label class="label">Contenido</label><textarea x-model="avanceForm.contenido" class="input-field w-full" rows="3" required></textarea></div>
</div>
<div class="flex justify-end gap-3 mt-4">
<button type="button" @click="showAvanceModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Publicar'"></button>
</div>
</form>
</div>
</div>
<!-- ─── Modal Entregable ──────────────────────────────────────────────────── -->
<div x-show="showEntregableModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showEntregableModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Subir entregable</h2>
<form @submit.prevent="saveEntregable()" enctype="multipart/form-data">
<div class="space-y-3">
<div><label class="label">Nombre</label><input x-model="entregableForm.nombre" class="input-field w-full" required placeholder="Ej: Diseño final v2"></div>
<div><label class="label">Descripción</label><input x-model="entregableForm.descripcion" class="input-field w-full"></div>
<div><label class="label">Versión</label><input x-model="entregableForm.version" class="input-field w-full" placeholder="1.0"></div>
<div><label class="label">Archivo</label><input type="file" @change="entregableFile=$event.target.files[0]" class="input-field w-full" required></div>
</div>
<p x-show="entregableError" x-text="entregableError" class="text-red-500 text-sm mt-2"></p>
<div class="flex justify-end gap-3 mt-4">
<button type="button" @click="showEntregableModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Subiendo...':'Subir'"></button>
</div>
</form>
</div>
</div>
</div>
<script>
function proyectoDetalle(proyectoId, slug) {
return {
proyectoId, slug,
activeTab: 'Fases',
fases: [], avances: [], entregables: [], tickets: [],
saving: false,
showFaseModal: false, faseEditId: null,
faseForm: { nombre:'', descripcion:'', estado:'pendiente', orden:1, fecha_estimada:'' },
showAvanceModal: false,
avanceForm: { tipo:'update', titulo:'', contenido:'', visible:true },
showEntregableModal: false,
entregableForm: { nombre:'', descripcion:'', version:'1.0' },
entregableFile: null, entregableError:'',
async init() {
await Promise.all([this.loadFases(), this.loadAvances(), this.loadEntregables(), this.loadTickets()]);
},
async loadFases() {
const r = await axios.get(`/app/proyectos/${this.proyectoId}/fases`);
this.fases = r.data || [];
},
async loadAvances() {
const r = await axios.get(`/app/proyectos/${this.proyectoId}/avances`);
this.avances = r.data || [];
},
async loadEntregables() {
const r = await axios.get(`/app/proyectos/${this.proyectoId}/entregables`);
this.entregables = r.data || [];
},
async loadTickets() {
const r = await axios.get(`/app/proyectos/${this.proyectoId}/tickets`);
this.tickets = (r.data || []).map(t => ({ ...t, _open: false, _reply: '' }));
},
// ─── Fases ───
openFaseModal() { this.faseEditId=null; this.faseForm={nombre:'',descripcion:'',estado:'pendiente',orden:this.fases.length+1,fecha_estimada:''}; this.showFaseModal=true; },
editFase(f) { this.faseEditId=f.ID; this.faseForm={nombre:f.nombre,descripcion:f.descripcion||'',estado:f.estado,orden:f.orden,fecha_estimada:f.fecha_estimada?f.fecha_estimada.substring(0,10):''}; this.showFaseModal=true; },
async saveFase() {
this.saving=true;
try {
if(this.faseEditId) await axios.put(`/app/proyectos/${this.proyectoId}/fases/${this.faseEditId}`, this.faseForm);
else await axios.post(`/app/proyectos/${this.proyectoId}/fases`, this.faseForm);
this.showFaseModal=false;
await this.loadFases();
} finally { this.saving=false; }
},
async deleteFase(id) {
if(!confirm('¿Eliminar esta fase?')) return;
await axios.delete(`/app/proyectos/${this.proyectoId}/fases/${id}`);
await this.loadFases();
},
async applyTemplate() {
if(!confirm('Esto reemplazará todas las fases actuales con la plantilla. ¿Continuar?')) return;
const r = await axios.post(`/app/proyectos/${this.proyectoId}/fases/template`);
this.fases = r.data || [];
},
// ─── Avances ───
openAvanceModal() { this.avanceForm={tipo:'update',titulo:'',contenido:'',visible:true}; this.showAvanceModal=true; },
async saveAvance() {
this.saving=true;
try {
await axios.post(`/app/proyectos/${this.proyectoId}/avances`, this.avanceForm);
this.showAvanceModal=false;
await this.loadAvances();
} finally { this.saving=false; }
},
async deleteAvance(id) {
if(!confirm('¿Eliminar avance?')) return;
await axios.delete(`/app/proyectos/${this.proyectoId}/avances/${id}`);
await this.loadAvances();
},
async toggleAvanceVisible(av) {
await axios.put(`/app/proyectos/${this.proyectoId}/avances/${av.ID}`, {...av, visible:!av.visible});
await this.loadAvances();
},
// ─── Entregables ───
openEntregableModal() { this.entregableForm={nombre:'',descripcion:'',version:'1.0'}; this.entregableFile=null; this.entregableError=''; this.showEntregableModal=true; },
async saveEntregable() {
if(!this.entregableFile) { this.entregableError='Selecciona un archivo'; return; }
this.saving=true; this.entregableError='';
try {
const fd = new FormData();
fd.append('nombre', this.entregableForm.nombre);
fd.append('descripcion', this.entregableForm.descripcion);
fd.append('version', this.entregableForm.version);
fd.append('archivo', this.entregableFile);
await axios.post(`/app/proyectos/${this.proyectoId}/entregables`, fd, { headers:{'Content-Type':'multipart/form-data'} });
this.showEntregableModal=false;
await this.loadEntregables();
} catch(e) {
this.entregableError = e.response?.data?.error || 'Error al subir';
} finally { this.saving=false; }
},
async deleteEntregable(id) {
if(!confirm('¿Eliminar entregable?')) return;
await axios.delete(`/app/proyectos/${this.proyectoId}/entregables/${id}`);
await this.loadEntregables();
},
async toggleEntregableVisible(e) {
await axios.put(`/app/proyectos/${this.proyectoId}/entregables/${e.ID}/visibilidad`, {visible: !e.visible});
await this.loadEntregables();
},
// ─── Tickets ───
async cambiarEstado(t, estado) {
await axios.put(`/app/proyectos/${this.proyectoId}/tickets/${t.ID}/estado`, {estado});
t.estado = estado;
},
async responder(t) {
if(!t._reply?.trim()) return;
const r = await axios.post(`/app/proyectos/${this.proyectoId}/tickets/${t.ID}/mensaje`, {contenido: t._reply});
if(!t.mensajes) t.mensajes=[];
t.mensajes.push(r.data);
t._reply='';
},
// ─── Helpers ───
faseBadgeClass(e) { return {pendiente:'badge-slate',en_progreso:'badge-yellow',completado:'badge-green',bloqueado:'badge-red'}[e]||'badge-slate'; },
avanceBadgeClass(t) { return {update:'badge-blue',milestone:'badge-green',nota:'badge-slate',alerta:'badge-yellow'}[t]||'badge-slate'; },
ticketBadge(e) { return {abierto:'badge-red',en_progreso:'badge-yellow',resuelto:'badge-green',cerrado:'badge-slate'}[e]||'badge-slate'; },
prioridadBadge(p) { return {alta:'badge-red',media:'badge-yellow',baja:'badge-green'}[p]||'badge-slate'; },
estadoColor(e) { return {activo:'green',pausado:'yellow',completado:'slate',cancelado:'red'}[e]||'slate'; },
formatDate(d) { if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric'}); },
formatBytes(b) { if(!b) return '0B'; if(b<1024) return b+'B'; if(b<1048576) return (b/1024).toFixed(1)+'KB'; return (b/1048576).toFixed(1)+'MB'; },
}
}
</script>
<style>
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; }
.btn-primary:hover { background:#6d8c24; }
.btn-secondary { background:#f1f5f9; color:#475569; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:500; font-size:.875rem; border:1px solid #e2e8f0; }
.btn-icon { display:inline-flex; align-items:center; padding:0.25rem; border-radius:0.375rem; }
.btn-icon:hover { background:#f1f5f9; }
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; }
.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; text-transform:capitalize; }
.badge-green { background:#dcfce7; color:#15803d; }
.badge-yellow { background:#fef9c3; color:#854d0e; }
.badge-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.badge-blue { background:#dbeafe; color:#1d4ed8; }
.bg-primary\/10 { background:rgba(142,176,47,.1); }
</style>
+244
View File
@@ -0,0 +1,244 @@
{{template "layouts/main" .}}
<div x-data="proyectosApp()" x-init="init()" class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Proyectos</h1>
<p class="text-sm text-slate-500 mt-1">Gestión de proyectos del portal de clientes</p>
</div>
<button @click="openCreate()" class="btn-primary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
Nuevo proyecto
</button>
</div>
<!-- Buscador -->
<div class="mb-4 flex gap-3">
<input x-model="search" @input.debounce.400ms="page=1;load()" type="text" placeholder="Buscar proyecto..." class="input-field w-full max-w-sm">
</div>
<!-- Tabla -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 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-4 py-3 text-left">Color</th>
<th class="px-4 py-3 text-left">Nombre</th>
<th class="px-4 py-3 text-left">Cliente</th>
<th class="px-4 py-3 text-left">Estado</th>
<th class="px-4 py-3 text-left">Progreso</th>
<th class="px-4 py-3 text-left">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-if="loading">
<tr><td colspan="6" class="text-center py-10 text-slate-400">Cargando...</td></tr>
</template>
<template x-if="!loading && items.length === 0">
<tr><td colspan="6" class="text-center py-10 text-slate-400">Sin proyectos</td></tr>
</template>
<template x-for="p in items" :key="p.ID">
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<span class="w-5 h-5 rounded-full inline-block border border-slate-200" :style="`background:${p.color}`"></span>
</td>
<td class="px-4 py-3 font-medium text-slate-800">
<a :href="`/app/proyectos/${p.ID}/detalle`" class="hover:underline text-primary" x-text="p.nombre"></a>
</td>
<td class="px-4 py-3 text-slate-600" x-text="p.cliente?.razon_social || p.cliente?.nombre || '-'"></td>
<td class="px-4 py-3">
<span class="badge" :class="{
'badge-green': p.estado==='activo',
'badge-yellow': p.estado==='pausado',
'badge-slate': p.estado==='completado',
'badge-red': p.estado==='cancelado'
}" x-text="p.estado"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-24 bg-slate-200 rounded-full h-2">
<div class="h-2 rounded-full" style="background:#8eb02f" :style="`width:${p.progreso||0}%`"></div>
</div>
<span class="text-xs text-slate-500" x-text="`${p.progreso||0}%`"></span>
</div>
</td>
<td class="px-4 py-3 flex items-center gap-2">
<a :href="`/app/proyectos/${p.ID}/detalle`" class="btn-icon text-blue-500" title="Detalle">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<button @click="openEdit(p)" class="btn-icon text-yellow-500" title="Editar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="confirmDelete(p)" class="btn-icon text-red-500" title="Eliminar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex justify-between items-center mt-4 text-sm text-slate-500">
<span>Total: <strong x-text="total"></strong></span>
<div class="flex gap-1">
<button @click="page--; load()" :disabled="page<=1" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Ant</button>
<span class="px-3 py-1" x-text="`${page} / ${totalPages||1}`"></span>
<button @click="page++; load()" :disabled="page>=totalPages" class="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">Sig</button>
</div>
</div>
<!-- Modal crear/editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
<h2 class="text-lg font-bold text-slate-800 mb-4" x-text="editId ? 'Editar proyecto' : 'Nuevo proyecto'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="label">Cliente</label>
<select x-model="form.cliente_id" class="input-field w-full" required>
<option value="">Seleccionar cliente</option>
<template x-for="c in clientes" :key="c.ID">
<option :value="c.ID" x-text="c.razon_social || c.nombre"></option>
</template>
</select>
</div>
<div class="col-span-2">
<label class="label">Nombre</label>
<input x-model="form.nombre" type="text" class="input-field w-full" required placeholder="Nombre del proyecto">
</div>
<div>
<label class="label">Slug</label>
<input x-model="form.slug" type="text" class="input-field w-full" placeholder="se genera automático">
</div>
<div>
<label class="label">Color</label>
<input x-model="form.color" type="color" class="h-10 w-full rounded border border-slate-200 cursor-pointer">
</div>
<div class="col-span-2">
<label class="label">Descripción</label>
<textarea x-model="form.descripcion" class="input-field w-full" rows="2"></textarea>
</div>
<div>
<label class="label">Estado</label>
<select x-model="form.estado" class="input-field w-full">
<option value="activo">Activo</option>
<option value="pausado">Pausado</option>
<option value="completado">Completado</option>
<option value="cancelado">Cancelado</option>
</select>
</div>
<div>
<label class="label">Progreso (%)</label>
<input x-model.number="form.progreso" type="number" min="0" max="100" class="input-field w-full">
</div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<div class="flex justify-end gap-3 mt-5">
<button type="button" @click="showModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving ? 'Guardando...' : 'Guardar'"></button>
</div>
</form>
</div>
</div>
<!-- Modal eliminar -->
<div x-show="showDelete" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showDelete=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold text-slate-800 mb-2">¿Eliminar proyecto?</h2>
<p class="text-slate-600 text-sm mb-5">Esta acción no se puede deshacer. Se eliminarán todas las fases, avances, entregables y tickets asociados.</p>
<div class="flex justify-end gap-3">
<button @click="showDelete=false" class="btn-secondary">Cancelar</button>
<button @click="doDelete()" :disabled="saving" class="btn-danger" x-text="saving ? 'Eliminando...' : 'Eliminar'"></button>
</div>
</div>
</div>
</div>
<script>
function proyectosApp() {
return {
items: [], clientes: [], total: 0, totalPages: 1, page: 1, search: '',
loading: false, saving: false, showModal: false, showDelete: false,
editId: null, deleteId: null, error: '',
form: { cliente_id: '', nombre: '', slug: '', descripcion: '', color: '#8eb02f', estado: 'activo', progreso: 0 },
async init() { await this.load(); },
async load() {
this.loading = true;
try {
const r = await axios.get(`/app/loadproyectos?page=${this.page}&search=${encodeURIComponent(this.search)}`);
this.items = r.data.items || [];
this.total = r.data.total;
this.totalPages = r.data.totalPages;
this.clientes = r.data.clientes || [];
} finally { this.loading = false; }
},
openCreate() {
this.editId = null;
this.error = '';
this.form = { cliente_id: '', nombre: '', slug: '', descripcion: '', color: '#8eb02f', estado: 'activo', progreso: 0 };
this.showModal = true;
},
openEdit(p) {
this.editId = p.ID;
this.error = '';
this.form = { cliente_id: p.cliente_id, nombre: p.nombre, slug: p.slug, descripcion: p.descripcion||'', color: p.color||'#8eb02f', estado: p.estado, progreso: p.progreso||0 };
this.showModal = true;
},
async save() {
this.saving = true; this.error = '';
try {
if (this.editId) {
await axios.put(`/app/proyectos/${this.editId}`, this.form);
} else {
await axios.post('/app/proyectos', this.form);
}
this.showModal = false;
await this.load();
} catch(e) {
this.error = e.response?.data?.error || 'Error al guardar';
} finally { this.saving = false; }
},
confirmDelete(p) { this.deleteId = p.ID; this.showDelete = true; },
async doDelete() {
this.saving = true;
try {
await axios.delete(`/app/proyectos/${this.deleteId}`);
this.showDelete = false;
await this.load();
} catch(e) {
alert(e.response?.data?.error || 'Error al eliminar');
} finally { this.saving = false; }
}
}
}
</script>
<style>
.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-secondary { background:#f1f5f9; color:#475569; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:500; font-size:.875rem; border:1px solid #e2e8f0; }
.btn-danger { background:#ef4444; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; }
.btn-icon { display:inline-flex; align-items:center; padding:0.25rem; border-radius:0.375rem; }
.btn-icon:hover { background:#f1f5f9; }
.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; text-transform:capitalize; }
.badge-green { background:#dcfce7; color:#15803d; }
.badge-yellow { background:#fef9c3; color:#854d0e; }
.badge-slate { background:#f1f5f9; color:#475569; }
.badge-red { background:#fee2e2; color:#991b1b; }
.text-primary { color:#8eb02f; }
</style>