Files
2026-05-25 12:50:53 -05:00

517 lines
32 KiB
HTML

<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.Empresa }}{{ .proyecto.Cliente.Empresa }}{{ else if .proyecto.Cliente.Nombre }}{{ .proyecto.Cliente.Nombre }}{{ end }} &middot; slug: <code>{{ .proyecto.Slug }}</code></p>
</div>
<div class="ml-auto flex items-center gap-3">
{{ if eq .proyecto.Estado "activo" }}<span class="badge badge-green">Activo</span>{{ else if eq .proyecto.Estado "pausado" }}<span class="badge badge-yellow">Pausado</span>{{ else if eq .proyecto.Estado "completado" }}<span class="badge badge-slate">Completado</span>{{ else }}<span class="badge badge-slate">{{ .proyecto.Estado }}</span>{{ end }}
<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','Documentos','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 ? 'Visible en portal · clic para ocultar' : 'Oculto en portal · clic para publicar'"
class="btn-icon flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors"
:class="av.visible ? 'text-green-600 border-green-200 bg-green-50 hover:bg-green-100' : 'text-slate-400 border-slate-200 bg-slate-50 hover:bg-slate-100'">
<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="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 x-text="av.visible ? 'Visible' : 'Oculto'"></span>
</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 Documentos ────────────────────────────────────────────────────── -->
<div x-show="activeTab==='Documentos'" x-cloak>
<div class="flex justify-between mb-4">
<h2 class="font-semibold text-slate-700">Contratos y órdenes de servicio</h2>
<button @click="openDocumentoModal()" class="btn-primary text-xs">+ Subir documento</button>
</div>
<div class="space-y-2">
<template x-for="d in documentos" :key="d.ID">
<div class="bg-white border border-slate-200 rounded-xl p-4 flex items-center gap-4">
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center" :style="docTipoColor(d.tipo)">
<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="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">
<div class="font-semibold text-sm text-slate-800" x-text="d.nombre"></div>
<div class="flex items-center gap-2 mt-0.5">
<span class="badge" :class="docBadgeClass(d.tipo)" x-text="docTipoLabel(d.tipo)"></span>
<span class="text-xs text-slate-400" x-text="formatBytes(d.tamanio)"></span>
<span class="text-xs text-slate-400" x-text="formatDate(d.CreatedAt)"></span>
</div>
<div class="text-xs text-slate-500 mt-0.5" x-text="d.descripcion"></div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<a :href="`/app/proyectos/{{ .proyecto.ID }}/documentos/${d.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>
<button @click="deleteDocumento(d.ID)" class="btn-icon text-red-400" 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>
</div>
</div>
</template>
<p x-show="documentos.length===0" class="text-slate-400 text-sm text-center py-8">Sin documentos. Sube un contrato u orden de servicio.</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>
<!-- ─── Modal Documento ────────────────────────────────────────────────────── -->
<div x-show="showDocumentoModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showDocumentoModal=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 documento</h2>
<form @submit.prevent="saveDocumento()" enctype="multipart/form-data">
<div class="space-y-3">
<div>
<label class="label">Tipo</label>
<select x-model="documentoForm.tipo" class="input-field w-full">
<option value="contrato">Contrato</option>
<option value="orden_servicio">Orden de servicio</option>
<option value="otro">Otro</option>
</select>
</div>
<div><label class="label">Nombre</label><input x-model="documentoForm.nombre" class="input-field w-full" placeholder="Ej: Contrato firmado cliente"></div>
<div><label class="label">Descripción</label><input x-model="documentoForm.descripcion" class="input-field w-full" placeholder="Opcional"></div>
<div><label class="label">Archivo</label><input type="file" @change="documentoFile=$event.target.files[0]" class="input-field w-full" required accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.xlsx,.xls"></div>
</div>
<p x-show="documentoError" x-text="documentoError" class="text-red-500 text-sm mt-2"></p>
<div class="flex justify-end gap-3 mt-4">
<button type="button" @click="showDocumentoModal=false" class="btn-secondary">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Subiendo...':'Subir'"></button>
</div>
</form>
</div>
</div>
<!-- Toast -->
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-6 right-6 z-50 px-5 py-3 rounded-xl shadow-lg text-white text-sm font-medium flex items-center gap-2"
:class="toast.type === 'error' ? 'bg-red-500' : 'bg-[#8eb02f]'">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path x-show="toast.type !== 'error'" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
<path x-show="toast.type === 'error'" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span x-text="toast.msg"></span>
</div>
</div>
<script>
function proyectoDetalle(proyectoId, slug) {
return {
proyectoId, slug,
activeTab: 'Fases',
fases: [], avances: [], entregables: [], tickets: [], documentos: [],
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:'',
showDocumentoModal: false,
documentoForm: { tipo:'contrato', nombre:'', descripcion:'' },
documentoFile: null, documentoError:'',
toast: { show: false, msg: '', type: 'ok' },
async init() {
await Promise.all([this.loadFases(), this.loadAvances(), this.loadEntregables(), this.loadTickets(), this.loadDocumentos()]);
},
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: '' }));
},
async loadDocumentos() {
const r = await axios.get(`/app/proyectos/${this.proyectoId}/documentos`);
this.documentos = r.data || [];
},
// ─── 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) {
const nuevoEstado = !av.visible;
await axios.put(`/app/proyectos/${this.proyectoId}/avances/${av.ID}`, {...av, visible:nuevoEstado});
await this.loadAvances();
this.showToast(nuevoEstado ? 'Avance visible en el portal del cliente' : 'Avance oculto en el portal del cliente');
},
// ─── 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();
},
// ─── Documentos ───
openDocumentoModal() { this.documentoForm={tipo:'contrato',nombre:'',descripcion:''}; this.documentoFile=null; this.documentoError=''; this.showDocumentoModal=true; },
async saveDocumento() {
if(!this.documentoFile) { this.documentoError='Selecciona un archivo'; return; }
this.saving=true; this.documentoError='';
try {
const fd = new FormData();
fd.append('tipo', this.documentoForm.tipo);
fd.append('nombre', this.documentoForm.nombre);
fd.append('descripcion', this.documentoForm.descripcion);
fd.append('archivo', this.documentoFile);
await axios.post(`/app/proyectos/${this.proyectoId}/documentos`, fd, { headers:{'Content-Type':'multipart/form-data'} });
this.showDocumentoModal=false;
await this.loadDocumentos();
} catch(e) {
this.documentoError = e.response?.data?.error || 'Error al subir';
} finally { this.saving=false; }
},
async deleteDocumento(id) {
if(!confirm('¿Eliminar este documento?')) return;
await axios.delete(`/app/proyectos/${this.proyectoId}/documentos/${id}`);
await this.loadDocumentos();
},
docBadgeClass(t) { return {contrato:'badge-blue',orden_servicio:'badge-green',otro:'badge-slate'}[t]||'badge-slate'; },
docTipoLabel(t) { return {contrato:'Contrato',orden_servicio:'Orden de servicio',otro:'Otro'}[t]||t; },
docTipoColor(t) { const m={contrato:'background:#dbeafe;color:#1d4ed8',orden_servicio:'background:#dcfce7;color:#15803d',otro:'background:#f1f5f9;color:#475569'}; return m[t]||m.otro; },
// ─── 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='';
},
showToast(msg, type = 'ok') {
this.toast = { show: true, msg, type };
setTimeout(() => { this.toast.show = false; }, 3000);
},
// ─── 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>