This commit is contained in:
Lizandro Guarnizo
2026-05-18 19:17:05 -05:00
parent 4b2003c752
commit 25f21be966
5 changed files with 230 additions and 3 deletions
+1
View File
@@ -80,6 +80,7 @@ func Migrate() {
&models.ProyectoFase{},
&models.ProyectoAvance{},
&models.ProyectoEntregable{},
&models.ProyectoDocumento{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.Factura{},
+36
View File
@@ -276,3 +276,39 @@ func DeleteProyectoEntregable(id uint) error {
func UpdateEntregableVisibilidad(id uint, visible bool) error {
return app.Http.Database.DB.Model(&ProyectoEntregable{}).Where("id = ?", id).Update("visible", visible).Error
}
// ─── ProyectoDocumento (contratos y órdenes de servicio) ──────────────────────
type ProyectoDocumento struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;default:'contrato'"` // contrato|orden_servicio|otro
Nombre string `json:"nombre" gorm:"column:nombre"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Archivo string `json:"archivo" gorm:"column:archivo"`
OriginalName string `json:"original_name" gorm:"column:original_name"`
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
}
func (ProyectoDocumento) TableName() string { return "proyecto_documentos" }
func GetDocumentosByProyecto(proyectoID uint) ([]ProyectoDocumento, error) {
var items []ProyectoDocumento
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateProyectoDocumento(d *ProyectoDocumento) error {
return app.Http.Database.DB.Create(d).Error
}
func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) {
var item ProyectoDocumento
err := app.Http.Database.DB.First(&item, id).Error
return &item, err
}
func DeleteProyectoDocumento(id uint) error {
return app.Http.Database.DB.Delete(&ProyectoDocumento{}, id).Error
}
+99 -3
View File
@@ -20,7 +20,7 @@
<!-- Tabs -->
<div class="flex border-b border-slate-200 mb-6 gap-0">
<template x-for="tab in ['Fases','Avances','Entregables','Tickets']" :key="tab">
<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>
@@ -117,6 +117,41 @@
</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">
@@ -244,6 +279,33 @@
</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>
</div>
<script>
@@ -251,7 +313,7 @@ function proyectoDetalle(proyectoId, slug) {
return {
proyectoId, slug,
activeTab: 'Fases',
fases: [], avances: [], entregables: [], tickets: [],
fases: [], avances: [], entregables: [], tickets: [], documentos: [],
saving: false,
showFaseModal: false, faseEditId: null,
faseForm: { nombre:'', descripcion:'', estado:'pendiente', orden:1, fecha_estimada:'' },
@@ -260,9 +322,12 @@ function proyectoDetalle(proyectoId, slug) {
showEntregableModal: false,
entregableForm: { nombre:'', descripcion:'', version:'1.0' },
entregableFile: null, entregableError:'',
showDocumentoModal: false,
documentoForm: { tipo:'contrato', nombre:'', descripcion:'' },
documentoFile: null, documentoError:'',
async init() {
await Promise.all([this.loadFases(), this.loadAvances(), this.loadEntregables(), this.loadTickets()]);
await Promise.all([this.loadFases(), this.loadAvances(), this.loadEntregables(), this.loadTickets(), this.loadDocumentos()]);
},
async loadFases() {
@@ -281,6 +346,10 @@ function proyectoDetalle(proyectoId, slug) {
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; },
@@ -353,6 +422,33 @@ function proyectoDetalle(proyectoId, slug) {
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});
+90
View File
@@ -405,6 +405,96 @@ func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
}
// ─── Documentos (contratos / órdenes de servicio) ─────────────────────────────
func GetDocumentos(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
items, err := models.GetDocumentosByProyecto(uint(id))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
func UploadDocumento(c *fiber.Ctx) error {
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
if _, err := c.MultipartForm(); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
}
nombre := strings.TrimSpace(c.FormValue("nombre"))
descripcion := c.FormValue("descripcion")
tipo := c.FormValue("tipo", "contrato")
validTipos := map[string]bool{"contrato": true, "orden_servicio": true, "otro": true}
if !validTipos[tipo] {
tipo = "otro"
}
file, err := c.FormFile("archivo")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
}
if file.Size > 50*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
}
if nombre == "" {
nombre = file.Filename
}
dir := fmt.Sprintf("uploads/proyectos/%d/docs", proyID)
if err := os.MkdirAll(dir, 0755); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
}
safeFile := safeFilenameProyecto(file.Filename)
savePath := filepath.Join(dir, safeFile)
if err := c.SaveFile(file, savePath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
}
d := &models.ProyectoDocumento{
ProyectoID: uint(proyID),
Tipo: tipo,
Nombre: nombre,
Descripcion: descripcion,
Archivo: savePath,
OriginalName: file.Filename,
TipoMime: mimeFromHeader(file),
Tamanio: file.Size,
}
if err := models.CreateProyectoDocumento(d); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(d)
}
func DeleteDocumento(c *fiber.Ctx) error {
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
item, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
_ = os.Remove(item.Archivo)
if err := models.DeleteProyectoDocumento(uint(docID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DownloadDocumento(c *fiber.Ctx) error {
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
item, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
clean := filepath.Clean(item.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
return c.SendFile(clean)
}
// ─── Tickets (admin) ──────────────────────────────────────────────────────────
func GetTickets(c *fiber.Ctx) error {
+4
View File
@@ -251,6 +251,10 @@ func UserRoutes(app fiber.Router) {
protected.Get("/proyectos/:id/tickets", controllers.GetTickets)
protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
protected.Post("/proyectos/:id/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
protected.Get("/proyectos/:id/documentos", controllers.GetDocumentos)
protected.Post("/proyectos/:id/documentos", controllers.UploadDocumento)
protected.Delete("/proyectos/:id/documentos/:docID", controllers.DeleteDocumento)
protected.Get("/proyectos/:id/documentos/:docID/download", controllers.DownloadDocumento)
protected.Get("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex)
protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios)