398 lines
24 KiB
HTML
398 lines
24 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 }}{{ .proyecto.Cliente.RazonSocial }}{{ end }} · 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>
|