diff --git a/cosiap_api/solicitudes/urls.py b/cosiap_api/solicitudes/urls.py index 9dcf2f82b9d729711671c2489ed7cf0bcb9cbe86..308d00fb7334c312761e6a595d86251f0dceae60 100644 --- a/cosiap_api/solicitudes/urls.py +++ b/cosiap_api/solicitudes/urls.py @@ -16,4 +16,6 @@ urlpatterns = [ path('reportes/exportar/', views.ExportarReporteSolicitudes.as_view(), name='exportar_reportes'), path('reportes/exportar//', views.ExportarReporteSolicitudes.as_view(), name='exportar_reportes_pk'), path('calificar//', views.CalificarDocumento.as_view(), name='calificar_documentos'), + path('subir-convenio//', views.SubirConvenio.as_view(), name='subir_convenio_pk'), + path('subir-convenio/', views.SubirConvenio.as_view(), name='subir_convenio'), ] \ No newline at end of file diff --git a/cosiap_api/solicitudes/views.py b/cosiap_api/solicitudes/views.py index cfe36d2f3fcde9aca8042494221d50c66ec9ceec..3d9bb8dcb267a4eea978987c9b99eaed192426c8 100644 --- a/cosiap_api/solicitudes/views.py +++ b/cosiap_api/solicitudes/views.py @@ -3,13 +3,14 @@ # Versión: 1.0 from dynamic_tables.views import DynamicTableAPIView +from dynamic_formats.models import DynamicFormat from .models import Solicitud from users.permisos import es_admin, primer_login from rest_framework.permissions import IsAuthenticated from datetime import timedelta, datetime from common.views import BasePermissionAPIView from users.models import Solicitante -from .models import Solicitud +from .models import Solicitud, Convenio from notificaciones.mensajes import Mensaje from rest_framework.response import Response from rest_framework import status @@ -202,6 +203,57 @@ class SolicitarAPIView(BasePermissionAPIView): return Response(data, status=status.HTTP_400_BAD_REQUEST) + + +class SubirConvenio(BasePermissionAPIView): + ''' + Clase para permitir la subida de un convenio firmado en una solicitud aprobada. + ''' + + permission_classes_update = [IsAuthenticated] + permission_classes_list = [IsAuthenticated] + + + def get(self, request, *args, **kwargs): + ''' + Método para recuperar el formato default de los convenios + ''' + data = {} + try: + formato = DynamicFormat.objects.get(nombre="formato_convenio_default") + data["formato_default"] = formato.id + return Response(data, status= status.HTTP_200_OK) + except Exception as e: + Mensaje.error(data, str(e)) + return Response(data, status = status.HTTP_400_BAD_REQUEST) + + def put(self, request, *args, **kwargs): + ''' + Método put para la edción del campo convenio + ''' + data = {} + + try: + solicitud_id = kwargs['pk'] + solicitante = Solicitante.objects.get(id= request.user.id) + solicitud = Solicitud.objects.get(id= solicitud_id, solicitante= solicitante) + if solicitud.status == 'Aprobado': + convenio_file = request.data.get('convenio', None) + convenio = Convenio.objects.create(archivo=convenio_file) + convenio.save() + solicitud.convenio = convenio + solicitud.save() + Mensaje.success(data, 'Convenio subido exitosamente.') + return Response(data, status= status.HTTP_200_OK) + else: + Mensaje.error(data, "No se puede subir un convenio en una solicitud no aprobada.") + return Response(data, status = status.HTTP_400_BAD_REQUEST ) + + except Exception as e: + Mensaje.error(data, str(e)) + return Response(data, status = status.HTTP_400_BAD_REQUEST) + + class HistorialAPIVIew(BasePermissionAPIView): ''' APIView con la funcionalidad para ver el historial de apoyos de un solicitante diff --git a/cosiap_frontend/src/App.css b/cosiap_frontend/src/App.css index 66ef1ca4fbb6f18949ac4c6015b91be05475c14d..82cb9c049931514efb6d50a0fdf019d485872e32 100644 --- a/cosiap_frontend/src/App.css +++ b/cosiap_frontend/src/App.css @@ -141,7 +141,7 @@ textarea { } .add-button:hover { - background-color: #45a049; + background-color: #5b5f5b; } .delete-button { @@ -519,6 +519,17 @@ font-size: 16px; font-size: 1.2rem; margin-top: 20px; } + + .disabled-card { + opacity: 0.5; + background-color: #f0f0f0; + } + + .disabled-card input, + .disabled-card .button-container button:not(.add-button) { + pointer-events: none; + opacity: 0.5; + } /* Estilos de las cards de las secciones */ .section-card { @@ -542,6 +553,15 @@ font-size: 16px; padding-bottom: 10px; } +.subtitle{ + font-size: 20px; + margin-bottom: 8px; + color: brown; + text-align: center; + position: relative; + padding-bottom: 10px; +} + /* Línea decorativa debajo del h3 */ .section-card h3::after { content: ""; diff --git a/cosiap_frontend/src/api.js b/cosiap_frontend/src/api.js index 819387c6d297ca60f482ce6efc7aded2ab877d48..4153462f302e40338fcbf3ba66bc6f1ff12daa42 100644 --- a/cosiap_frontend/src/api.js +++ b/cosiap_frontend/src/api.js @@ -124,6 +124,10 @@ const api = { get: () => ax.get('api/solicitudes/historial'), getById: (id) => ax.get(`api/solicitudes/historial/${id}`), }, + convenio: { + update: (id,data) => ax.put(`api/solicitudes/subir-convenio/${id}/`, data), + get: () => ax.get('api/solicitudes/subir-convenio/') + }, reportes: { get: () => ax.get('api/solicitudes/reportes'), getById: (id) => ax.get(`api/solicitudes/reportes/${id}`), diff --git a/cosiap_frontend/src/components/admin/HistorialAdmin.jsx b/cosiap_frontend/src/components/admin/HistorialAdmin.jsx index a4485511e86b9e0af94dbd889212a598e90f5c51..9b79c0d7c25935ca874e3a5bf30f816b8468b6f4 100644 --- a/cosiap_frontend/src/components/admin/HistorialAdmin.jsx +++ b/cosiap_frontend/src/components/admin/HistorialAdmin.jsx @@ -19,7 +19,6 @@ const ListaSolicitudesSolicitante = () => { try { const response = await api.solicitudes.historial.getById(id); const solicitudesData = Object.values(response.data) || []; - // Guardamos las solicitudes setSolicitudes(solicitudesData); @@ -43,8 +42,9 @@ const ListaSolicitudesSolicitante = () => { setModalidades(modalidadNombres); } catch (error) { - console.error("Error al obtener la lista de solicitudes", error); + navigate('/404'); setSolicitudes([]); + return; } }; @@ -75,9 +75,16 @@ const ListaSolicitudesSolicitante = () => { { label: "Estatus", render: (fila) => ( - - {fila.status} - +
+ + {fila.status} + + {fila.status === "Aprobado" && !fila.convenio && ( +

+ *No se ha subido el convenio* +

+ )} +
) }, { diff --git a/cosiap_frontend/src/components/modalidades/EditarModalidad.jsx b/cosiap_frontend/src/components/modalidades/EditarModalidad.jsx index 016b301ad4a0f72a271fbc96283f611358c76606..f815eaf01d2e0cb897451111cbd76bb9d3e60cb0 100644 --- a/cosiap_frontend/src/components/modalidades/EditarModalidad.jsx +++ b/cosiap_frontend/src/components/modalidades/EditarModalidad.jsx @@ -40,7 +40,7 @@ const EditModalidad = () => { const fetchModalidad = async () => { try { const response = await api.modalidades.getById(id); - const modalidadData = response.data; + const modalidadData = response.data || []; setNombre(modalidadData.data.nombre); setDescripcion(modalidadData.data.descripcion); setMontoMaximo(modalidadData.data.monto_maximo); @@ -89,7 +89,8 @@ const EditModalidad = () => { } catch (error) { - console.error("Error al obtener la modalidad", error); + navigate('/404'); + return; } }; diff --git a/cosiap_frontend/src/components/modalidades/Modalidad.jsx b/cosiap_frontend/src/components/modalidades/Modalidad.jsx index f70ca0cdd418835743827a17aa460cbf6178146e..9f631e6cb83b332fd8d5ff7f99bfa98ce4edde9e 100644 --- a/cosiap_frontend/src/components/modalidades/Modalidad.jsx +++ b/cosiap_frontend/src/components/modalidades/Modalidad.jsx @@ -63,7 +63,8 @@ const SolicitarModalidad = () => { console.log("Modalidad extraída con éxito", response); console.log(response.data); } catch (error) { - console.error("Modalidad no extraída", error); + navigate('/404'); + return; } }; diff --git a/cosiap_frontend/src/components/solicitudes/HistorialSolicitudes.jsx b/cosiap_frontend/src/components/solicitudes/HistorialSolicitudes.jsx index 5c9628135b9ba1e43ee058ff465a70f0cdb0b9df..1ce60b946e82a944a4e1348bd1d519ddb564a953 100644 --- a/cosiap_frontend/src/components/solicitudes/HistorialSolicitudes.jsx +++ b/cosiap_frontend/src/components/solicitudes/HistorialSolicitudes.jsx @@ -42,7 +42,7 @@ const ListaSolicitudes = () => { setModalidades(modalidadNombres); } catch (error) { - console.error("Error al obtener la lista de solicitudes", error); + navigate('/404'); setSolicitudes([]); } }; @@ -74,9 +74,16 @@ const ListaSolicitudes = () => { { label: "Estatus", render: (fila) => ( - - {fila.status} - +
+ + {fila.status} + + {fila.status === "Aprobado" && !fila.convenio && ( +

+ *No se ha subido el convenio* +

+ )} +
) }, { @@ -114,7 +121,7 @@ const ListaSolicitudes = () => { ]; return ( - +
diff --git a/cosiap_frontend/src/components/solicitudes/VerSolicitud.jsx b/cosiap_frontend/src/components/solicitudes/VerSolicitud.jsx index 63fa0db57cc5f208005d7be7129d052f54d61da8..7e1fe6c903aa9a30899ead5517644d81f087a735 100644 --- a/cosiap_frontend/src/components/solicitudes/VerSolicitud.jsx +++ b/cosiap_frontend/src/components/solicitudes/VerSolicitud.jsx @@ -1,9 +1,9 @@ import { useState, useEffect } from "react"; -import api from '../../api'; +import api from "../../api"; import MainContainer from "../common/utility/MainContainer"; -import { useParams} from 'react-router-dom'; -import { renderElemento } from "@/components/common/utility/RenderElementView"; -import '@/App.css'; +import { useParams } from "react-router-dom"; +import { renderElemento } from "@/components/common/utility/RenderElementView"; +import "@/App.css"; const VisualizarSolicitud = () => { const { id } = useParams(); @@ -11,30 +11,34 @@ const VisualizarSolicitud = () => { const [solicitud, setSolicitud] = useState(null); const [modalidad, setModalidad] = useState(''); const [respuesta, setRespuesta] = useState([]); + const [convenio, setConvenio] = useState(null); + const [alertMessage, setAlertMessage] = useState(''); + const [isSuccess, setIsSuccess] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [formato_estandar, setFormatoEstandar] = useState(null); + const [userAdmin, setUserAdmin] = useState(false); - // Obtenemos los datos de la solicitud + // Obtener datos de la solicitud useEffect(() => { const fetchSolicitud = async () => { try { const response = await api.solicitudes.getById(id); setModalidad(response.data.modalidad); + setConvenio(response.data.convenio); + console.log("Convenio url: ", response.data.convenio) setSolicitud(response.data); setSecciones(response.data.formulario.secciones || {}); - const respuestasProcesadas = []; - // Recorrer todas las secciones - Object.values(response.data.formulario.secciones).forEach((seccion) => { - Object.values(seccion.elementos).forEach((elemento) => { - const respuesta = elemento.respuesta || {}; // Verificar si existe la respuesta - - respuestasProcesadas.push({ - elemento_id: elemento.id, - seccion_id: seccion.id, - valor: respuesta.valor || "", // Precargar valor si existe, o dejar vacío - status: respuesta.status || "", - observacion: respuesta.observacion || "" - }); - }); - }); + + const respuestasProcesadas = Object.values(response.data.formulario.secciones).flatMap((seccion) => + Object.values(seccion.elementos).map((elemento) => ({ + elemento_id: elemento.id, + seccion_id: seccion.id, + valor: elemento.respuesta?.valor || "", + status: elemento.respuesta?.status || "", + observacion: elemento.respuesta?.observacion || "", + })) + ); setRespuesta(respuestasProcesadas); } catch (error) { console.error("Error al obtener la solicitud", error); @@ -44,49 +48,114 @@ const VisualizarSolicitud = () => { fetchSolicitud(); }, [id]); - // Renderiza las secciones y sus elementos en modo solo lectura - const renderSecciones = () => { - return Object.values(secciones).map((seccion) => ( -
-

{seccion.nombre}

-
- {Object.values(seccion.elementos).map((elemento) => { - // Obtener el estado correspondiente del elemento en respuesta - const respuestaElement = respuesta.find(item => item.elemento_id === elemento.id); - const status = respuestaElement ? respuestaElement.status : ""; - - // Determinamos el borde del color del card basado en el status - let borderColorClass = ""; - switch (status) { - case "valido": - borderColorClass = "border-green"; // borde verde para válido - break; - case "revisando": - borderColorClass = "border-yellow"; // borde amarillo para revisando - break; - case "invalido": - borderColorClass = "border-red"; // borde rojo para inválido - break; - default: - borderColorClass = ""; // sin borde especial si no hay estado - } - - return ( -
-

{elemento.nombre}

- {renderElemento(seccion.id, elemento, null, null, respuesta)} -
- ); - })} -
-
- )); + useEffect(() => { + const fetchFormato = async () => { + try{ + const response = await api.solicitudes.convenio.get(); + console.log("formato:",response.data) + setFormatoEstandar(response.data.formato_default) + } catch (error) { + console.error("Error al obtener el formato", error); + setFormatoEstandar(null); + } + }; + fetchFormato(); + }, []); + + useEffect(() => { + const fetchUser = async () => { + try{ + const response = await api.usuarios.admin.is_admin(); + setUserAdmin(response.data.user_is_admin) + }catch (error) { + setUserAdmin(false); + } + }; + fetchUser(); + }); + + const showAlert = (message, isSuccess) => { + setAlertMessage(message); + setIsSuccess(isSuccess); + + setTimeout(() => { + setAlertMessage(''); + }, 3000); + }; + + const handleDownload = async (formato) => { + try { + const response = await api.formatos.getById(formato, { + responseType: 'blob', + }); + + const contentType = response.headers['content-type']; + console.log('Content Type:', contentType); + + if (contentType !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + throw new Error('Tipo de archivo inesperado'); + } + + const blob = new Blob([response.data], { type: contentType }); + const url = window.URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `convenio_formato.docx`); + + document.body.appendChild(link); + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('Error al descargar el formato:', error); + } + }; + + // Control de cambio en el archivo + const handleImageChange = (e) => { + setSelectedFile(e.target.files[0]); + }; + + // Guardar cambios en el convenio + const handleSave = async () => { + if (!selectedFile) { + showAlert("Debe seleccionar un archivo antes de guardar", false); + return; // Detener la ejecución si no hay archivo seleccionado + } + + const formData = new FormData(); + formData.append("convenio", selectedFile); + + try { + await api.solicitudes.convenio.update(id, formData); + showAlert("Convenio actualizado exitosamente", true); + setIsEditing(false); // Bloquear edición tras guardar + setSelectedFile(null); + + // Actualizar el convenio mostrado + const updatedSolicitud = await api.solicitudes.getById(id); + setConvenio(updatedSolicitud.data.convenio); + } catch (error) { + showAlert("Error al actualizar el convenio", false); + } + }; + + + // Cancelar edición + const handleCancel = () => { + setIsEditing(false); + setSelectedFile(null); }; return ( {solicitud && ( <> + {alertMessage && ( +
+ {alertMessage} +
+ )}
Modalidad
@@ -94,7 +163,9 @@ const VisualizarSolicitud = () => {

Modalidad:

{modalidad.nombre}

{modalidad.descripcion}

-

{new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(modalidad.monto_maximo)}

+

+ {new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(modalidad.monto_maximo)} +

@@ -104,8 +175,99 @@ const VisualizarSolicitud = () => {

Monto Aprobado: {solicitud.monto_aprobado}

+ {solicitud.status === "Aprobado" && ( +
+
+

Convenio

+ + {/* Mostrar el botón de "Descargar Formato" solo si no es admin */} + {!userAdmin && ( + + )} + + {convenio ? ( + <> + + Ver Documento + +
+ + ) : ( +

No se ha subido el convenio.

+ )} + +
+ + {/* Mostrar los botones de edición solo si no es admin */} + {!userAdmin && ( +
+ {!isEditing ? ( + + ) : ( + <> + +
+ + +
+ + )} +
+ )} +
+
+ )} +
- {renderSecciones()} + {Object.values(secciones).map((seccion) => ( +
+

{seccion.nombre}

+
+ {Object.values(seccion.elementos).map((elemento) => { + const respuestaElement = respuesta.find((item) => item.elemento_id === elemento.id); + const status = respuestaElement ? respuestaElement.status : ""; + const borderColorClass = + status === "valido" ? "border-green" : status === "revisando" ? "border-yellow" : status === "invalido" ? "border-red" : ""; + + return ( +
+

{elemento.nombre}

+ {renderElemento(seccion.id, elemento, null, null, respuesta)} +
+ ); + })} +
+
+ ))}
)}