+ {/* Botones de Acción */}
+
)
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/features/projects/components/RollbackProjectModal.jsx b/apps/web/src/features/projects/components/RollbackProjectModal.jsx
new file mode 100644
index 00000000..9f8dcb61
--- /dev/null
+++ b/apps/web/src/features/projects/components/RollbackProjectModal.jsx
@@ -0,0 +1,125 @@
+import React, { useState } from 'react';
+
+export default function RollbackProjectModal({
+ isOpen,
+ onClose,
+ project,
+ onConfirm,
+ isLoading
+}) {
+ const [confirmText, setConfirmText] = useState('');
+ const isConfirmValid = confirmText.trim().toUpperCase() === 'CONFIRMAR';
+
+ if (!isOpen) return null;
+
+ const handleConfirm = () => {
+ if (isConfirmValid && !isLoading) {
+ onConfirm();
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ Revertir Proyecto
+
+
+
+
+ {/* Content */}
+
+ {/* Advertencia principal */}
+
+
+
+
+
+ ADVERTENCIA: Esta acción es destructiva e irreversible
+
+
+ - El proyecto será eliminado permanentemente
+ - La solicitud volverá al estado "En Revisión"
+ - Estará disponible para que otro profesor la apruebe
+ - Todo el progreso del proyecto se perderá
+
+
+
+
+
+ {/* Información del proyecto */}
+
+
+ Proyecto: {project?.title || 'Sin título'}
+
+
+ Estado actual: {project?.status?.name || 'Desconocido'}
+
+
+
+ {/* Campo de confirmación */}
+
+
+ setConfirmText(e.target.value)}
+ disabled={isLoading}
+ placeholder="Escribe CONFIRMAR"
+ className="w-full px-4 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-red-500 dark:focus:border-red-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+
+
+ {/* Mensaje de validación */}
+ {confirmText && !isConfirmValid && (
+
+ El texto no coincide. Debe ser exactamente "CONFIRMAR" en mayúsculas.
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/components/StartProjectModal.jsx b/apps/web/src/features/projects/components/StartProjectModal.jsx
new file mode 100644
index 00000000..cef54252
--- /dev/null
+++ b/apps/web/src/features/projects/components/StartProjectModal.jsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import useProjectValidation from '../hooks/useProjectValidation';
+
+export default function StartProjectModal({
+ isOpen,
+ onClose,
+ project,
+ onConfirm,
+ isLoading
+}) {
+ const validation = useProjectValidation(project);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+ Iniciar Proyecto
+
+
+
+
+ {/* Content */}
+
+ {/* Validaciones */}
+
+
+
+
Equipo completo
+
+
+
+
+
Fecha límite válida
+
+
+
+ {/* Errores */}
+ {validation.errors.length > 0 && (
+
+
+ No se puede iniciar el proyecto:
+
+
+ {validation.errors.map((error, idx) => (
+ - {error}
+ ))}
+
+
+ {/* Mostrar detalles de roles faltantes */}
+ {validation.missingRoles && validation.missingRoles.length > 0 && (
+
+
+ Miembros faltantes por rol:
+
+
+ {validation.missingRoles.map((roleInfo, idx) => (
+ -
+
+
+ {roleInfo.role}: Tienes {roleInfo.current}, necesitas mínimo {roleInfo.min}
+ {roleInfo.max !== '∞' && ` (máximo ${roleInfo.max})`}
+ {' '}- Faltan {roleInfo.needed}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Advertencia */}
+ {validation.canStart && (
+
+
+ Una vez iniciado, no podrás modificar el equipo del proyecto.
+
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/components/UpdateDeadlineModal.jsx b/apps/web/src/features/projects/components/UpdateDeadlineModal.jsx
new file mode 100644
index 00000000..9c058c05
--- /dev/null
+++ b/apps/web/src/features/projects/components/UpdateDeadlineModal.jsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { parseDateLocal, formatDateSpanish } from '@/utils/dateUtils';
+
+export default function UpdateDeadlineModal({
+ isOpen,
+ onClose,
+ project,
+ onConfirm,
+ isLoading
+}) {
+ const [newDeadline, setNewDeadline] = useState('');
+ const [validationError, setValidationError] = useState('');
+
+ // Calcular fechas mínimas y máximas basadas en el tipo de proyecto
+ const dateConstraints = useMemo(() => {
+ if (!project?.createdAt || !project?.projectTypes?.[0]) {
+ return null;
+ }
+
+ const projectType = project.projectTypes[0];
+ const createdDate = parseDateLocal(project.createdAt.split('T')[0]);
+
+ const minMonths = projectType.minEstimatedMonths || 0;
+ // Calcular fechas mínima y máxima permitidas
+ // Mínimo: Fecha de creación + meses mínimos del tipo de proyecto
+ const minDate = new Date(createdDate);
+ minDate.setMonth(minDate.getMonth() + minMonths);
+
+ // Máximo: Regla de negocio específica -> 1 mes después de la fecha mínima
+ const maxDate = new Date(minDate);
+ maxDate.setMonth(maxDate.getMonth() + 1);
+
+ return {
+ minDate,
+ maxDate,
+ minDateString: minDate.toISOString().split('T')[0],
+ maxDateString: maxDate.toISOString().split('T')[0],
+ minMonths,
+ maxMonths: minMonths + 1 // Ajustamos visualmente para reflejar la regla
+ };
+ }, [project]);
+
+ // Inicializar con la fecha actual del proyecto o la mínima permitida
+ useEffect(() => {
+ if (isOpen && project?.estimatedDate && dateConstraints) {
+ const currentDeadline = project.estimatedDate.split('T')[0];
+
+ // Si la fecha actual es menor a la mínima permitida se usa la mínima
+ if (new Date(currentDeadline) < dateConstraints.minDate) {
+ setNewDeadline(dateConstraints.minDateString);
+ } else {
+ setNewDeadline(currentDeadline);
+ }
+ setValidationError('');
+ }
+ }, [isOpen, project, dateConstraints]);
+
+ // Valida la fecha en tiempo real
+ useEffect(() => {
+ if (!newDeadline || !dateConstraints) {
+ setValidationError('');
+ return;
+ }
+
+ const selectedDate = parseDateLocal(newDeadline);
+
+ // Normalizar a medianoche para comparar solo fechas sin horas
+ const selectedTime = new Date(selectedDate).setHours(0, 0, 0, 0);
+ const minTime = new Date(dateConstraints.minDate).setHours(0, 0, 0, 0);
+ const maxTime = new Date(dateConstraints.maxDate).setHours(0, 0, 0, 0);
+
+ if (selectedTime < minTime) {
+ setValidationError(
+ `La fecha seleccionada (${formatDateSpanish(selectedDate)}) es anterior al mínimo permitido: ${formatDateSpanish(dateConstraints.minDate)}`
+ );
+ } else if (selectedTime > maxTime) {
+ setValidationError(
+ `La fecha seleccionada (${formatDateSpanish(selectedDate)}) excede el límite permitido: ${formatDateSpanish(dateConstraints.maxDate)}`
+ );
+ } else {
+ setValidationError('');
+ }
+ }, [newDeadline, dateConstraints]);
+
+ if (!isOpen) return null;
+
+ const handleConfirm = () => {
+ if (!validationError && newDeadline && !isLoading) {
+ // formato YYYY-MM-DD
+ onConfirm(newDeadline);
+ }
+ };
+
+ const isValid = !validationError && newDeadline;
+
+ return (
+
+
+ {/* Header */}
+
+
+ Actualizar Fecha Límite
+
+
+
+
+ {/* Content */}
+
+ {/* Información del proyecto */}
+
+
+ Proyecto: {project?.title || 'Sin título'}
+
+
+ Tipo: {dateConstraints?.projectTypeName || 'No especificado'}
+
+
+
+ {/* Restricciones */}
+ {dateConstraints && (
+
+
+ Restricciones del tipo de proyecto:
+
+
+ -
+ Mínimo: {formatDateSpanish(dateConstraints.minDate)}
+ ({dateConstraints.minMonths} meses)
+
+ -
+ Máximo: {formatDateSpanish(dateConstraints.maxDate)}
+ ({dateConstraints.maxMonths} meses)
+
+
+
+ )}
+
+ {/* Campo de fecha */}
+
+
+ setNewDeadline(e.target.value)}
+ disabled={isLoading}
+ className="w-full px-4 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-lime-500 dark:focus:border-lime-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+
+
+ {/* Error de validación */}
+ {validationError && (
+
+
+
+ {validationError}
+
+
+ )}
+
+ {/* Vista previa */}
+ {isValid && (
+
+
+
+
+ Nueva fecha límite: {formatDateSpanish(parseDateLocal(newDeadline))}
+
+
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/components/archivo.md b/apps/web/src/features/projects/components/archivo.md
deleted file mode 100644
index 87ac4cf9..00000000
--- a/apps/web/src/features/projects/components/archivo.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Documentación de cambios — imágenes del formulario y serving de assets
-
-## Resumen ejecutivo
-
-Se corrigió el **frontend** para manejar previews de archivos locales, selección de banners predeterminados y keys en listas; y el **backend** para servir archivos estáticos con `Content-Length` correcto (evitando `ERR_CONTENT_LENGTH_MISMATCH`).
-
----
-
-## 1. Archivos modificados
-
-- `apps/web/src/features/.../RequestProjectForm.jsx`
-- `apps/api/routes/file/handlers.js`
-- `getMetadata.js` *(se mantiene igual, ya generaba `banner.url`)*
-
----
-
-## 2. Cambios en el frontend (`RequestProjectForm.jsx`)
-
-### Problemas antes
-- `key={index}` en `map` → warnings en consola.
-- URLs de preview locales no se revocaban → riesgo de memory leaks.
-- Inconsistencia entre `selectedDefaultImage` y `banner.uuid/url`.
-- `getFileIcon` intentaba usar una ref inexistente.
-
-### Cambios después
-- Se creó estado `selectedFilePreview` para manejar previews de archivos locales (`URL.createObjectURL`).
-- Se revoca el object URL anterior en cada cambio y en `useEffect` cleanup.
-- `handleFileInputChange` y `handleDefaultImageSelect` limpian estados para evitar mezclas entre archivo local y banner predeterminado.
-- `getFileIcon` ahora devuelve la preview cuando es imagen.
-- En `defaultBanners.map` se usa `key={banner.uuid}` en lugar de `index`.
-
-### Nota
-Ahora se recomienda guardar en `selectedDefaultImage` el `banner.uuid` y setear en el form el `banner.url`. Así se comparan ids únicos y se renderiza sin inconsistencias.
-
----
-
-## 3. Cambios en el backend (`getPublicAssetHandler`)
-
-### Problema antes
-- Se usaba `assetFile.fileSize` (DB) como `Content-Length`, pero podía diferir del tamaño real en disco → `ERR_CONTENT_LENGTH_MISMATCH`.
-
-### Solución
-- Se usa `fs.statSync(assetFile.storedPath).size` para el header `Content-Length`.
-- `fs.createReadStream` ahora maneja errores explícitamente.
-- Alternativa válida: `res.sendFile(assetFile.storedPath)` para que Express maneje headers.
-
----
-
-## 4. Impacto de los cambios
-
-- **Frontend:** previews correctas, sin fugas de memoria, sin warnings de React, selección consistente entre imágenes locales y predeterminadas.
-- **Backend:** `Content-Length` correcto → imágenes cargan completas, sin errores en navegador.
diff --git a/apps/web/src/features/projects/hooks/useApplicationDetails.js b/apps/web/src/features/projects/hooks/useApplicationDetails.js
index 00d37942..96db53b4 100644
--- a/apps/web/src/features/projects/hooks/useApplicationDetails.js
+++ b/apps/web/src/features/projects/hooks/useApplicationDetails.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { getApplicationDetails } from "../projectsService";
import { Alerts } from "@/shared/alerts";
import { getDisplayMessage, AuthenticationError, NotFoundError } from "@/utils/errorHandler";
@@ -10,45 +10,50 @@ export default function useApplicationDetails(uuid) {
const [error, setError] = useState(null);
const navigate = useNavigate();
- useEffect(() => {
+ const fetchDetails = useCallback(async () => {
if (!uuid) {
setError("UUID de aplicación no proporcionado");
setIsLoading(false);
return;
}
- const fetchDetails = async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const data = await getApplicationDetails(uuid);
- setApplication(data);
- } catch (err) {
- console.error("Error fetching application details:", err);
-
- if (err instanceof AuthenticationError) {
- Alerts.warning("Tu sesión ha expirado. Por favor, inicia sesión nuevamente.");
- setTimeout(() => navigate("/login"), 2000);
- return;
- }
-
- if (err instanceof NotFoundError) {
- Alerts.error("Proyecto no encontrado");
- setTimeout(() => navigate("/explore-projects"), 2000);
- return;
- }
-
- const errorMessage = getDisplayMessage(err);
- setError(errorMessage);
- Alerts.error(errorMessage);
- } finally {
- setIsLoading(false);
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const data = await getApplicationDetails(uuid);
+ setApplication(data);
+ } catch (err) {
+ console.error("Error fetching application details:", err);
+
+ if (err instanceof AuthenticationError) {
+ Alerts.warning("Tu sesión ha expirado. Por favor, inicia sesión nuevamente.");
+ setTimeout(() => navigate("/login"), 2000);
+ return;
}
- };
- fetchDetails();
+ if (err instanceof NotFoundError) {
+ Alerts.error("Proyecto no encontrado");
+ setTimeout(() => navigate("/explore-projects"), 2000);
+ return;
+ }
+
+ const errorMessage = getDisplayMessage(err);
+ setError(errorMessage);
+ Alerts.error(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
}, [uuid, navigate]);
- return { application, isLoading, error };
+ useEffect(() => {
+ fetchDetails();
+ }, [fetchDetails]);
+
+ return {
+ application,
+ isLoading,
+ error,
+ refetch: fetchDetails // ✅ Exponer función para refrescar
+ };
}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/hooks/useEditApplication.js b/apps/web/src/features/projects/hooks/useEditApplication.js
new file mode 100644
index 00000000..fc91d997
--- /dev/null
+++ b/apps/web/src/features/projects/hooks/useEditApplication.js
@@ -0,0 +1,416 @@
+import { useState, useEffect } from "react";
+import { updateApplication, approveApplication } from "../projectsService";
+import { Alerts } from "@/shared/alerts";
+import { ValidationError, getDisplayMessage, processFieldErrors } from "@/utils/errorHandler";
+
+export default function useEditApplication(uuid, onEditSuccess, onApproveSuccess, projectTypes = [], applicationCreatedAt) {
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [isLoading, setIsLoading] = useState(false);
+
+ const [form, setForm] = useState({
+ projectType: [],
+ faculty: [],
+ problemType: [],
+ problemTypeOther: "",
+ deadline: "",
+ });
+
+ const [deadlineConstraints, setDeadlineConstraints] = useState({
+ min: null,
+ max: null,
+ projectTypeName: null,
+ minMonths: 0,
+ maxMonths: 0,
+ applicationDate: null,
+ });
+
+ // Helper para formatear fecha local sin zona horaria
+ const formatDateLocal = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // Calcular constraints basados en la fecha de vigencia original
+ useEffect(() => {
+ if (form.projectType.length > 0 && projectTypes.length > 0 && applicationCreatedAt && form.deadline) {
+ const selectedTypeId = form.projectType[0];
+ const projectType = projectTypes.find(pt => pt.project_type_id === selectedTypeId);
+
+ if (projectType) {
+ // Fecha de solicitud (cuando se creó la Application)
+ const appDateStr = applicationCreatedAt.split('T')[0];
+ const [appYear, appMonth, appDay] = appDateStr.split('-').map(Number);
+ const applicationDate = new Date(appYear, appMonth - 1, appDay);
+
+ // Fecha mínima = fecha de vigencia original (la que seleccionó el usuario)
+ const [deadlineYear, deadlineMonth, deadlineDay] = form.deadline.split('-').map(Number);
+ const originalDeadline = new Date(deadlineYear, deadlineMonth - 1, deadlineDay);
+
+ // Fecha máxima = fecha mínima + 1 mes de buffer
+ const maxDate = new Date(originalDeadline);
+ maxDate.setMonth(maxDate.getMonth() + 1);
+
+ // Calcular meses entre solicitud y fecha mínima
+ const minMonths = Math.round(
+ (originalDeadline.getFullYear() - applicationDate.getFullYear()) * 12 +
+ (originalDeadline.getMonth() - applicationDate.getMonth())
+ );
+
+ setDeadlineConstraints({
+ min: formatDateLocal(originalDeadline),
+ max: formatDateLocal(maxDate),
+ projectTypeName: projectType.name,
+ minMonths,
+ maxMonths: minMonths + 1,
+ applicationDate: formatDateLocal(applicationDate)
+ });
+ }
+ } else {
+ setDeadlineConstraints({
+ min: null,
+ max: null,
+ projectTypeName: null,
+ minMonths: 0,
+ maxMonths: 0,
+ applicationDate: null
+ });
+ }
+ }, [form.projectType, form.deadline, projectTypes, applicationCreatedAt]);
+
+ // Inicializar formulario con datos de la aplicación
+ const initializeForm = (application) => {
+ setForm({
+ projectType: application.projectTypeIds || [],
+ faculty: application.facultyIds || [],
+ problemType: application.problemTypeIds || [],
+ problemTypeOther: application.problemTypeOther || "",
+ deadline: application.dueDate?.split('T')[0] || "",
+ });
+ };
+
+ // Manejar cambios en los campos del formulario
+ const handleChange = (e) => {
+ const { name, value, type, checked } = e.target;
+
+ // Limpiar error del campo cuando el usuario empieza a escribir
+ if (fieldErrors[name]) {
+ setFieldErrors(prev => {
+ const newErrors = { ...prev };
+ delete newErrors[name];
+ return newErrors;
+ });
+ }
+
+ // Radio button para faculty
+ if (type === "radio" && name === "faculty") {
+ setForm(prevForm => ({
+ ...prevForm,
+ faculty: [Number(value)]
+ }));
+ return;
+ }
+
+ // Checkboxes (projectType, faculty, problemType)
+ if (type === "checkbox") {
+ if (name === "projectType" || name === "faculty" || name === "problemType") {
+ setForm(prevForm => {
+ const currentArray = prevForm[name] || [];
+ const parsedValue = value === "otro" ? "otro" : Number(value);
+
+ if (checked) {
+ return {
+ ...prevForm,
+ [name]: [...currentArray, parsedValue]
+ };
+ } else {
+ const newArray = currentArray.filter(item => item !== parsedValue);
+
+ // Limpiar problemTypeOther si se deselecciona "otro"
+ if (name === "problemType" && parsedValue === "otro") {
+ return {
+ ...prevForm,
+ [name]: newArray,
+ problemTypeOther: "",
+ };
+ }
+
+ return {
+ ...prevForm,
+ [name]: newArray
+ };
+ }
+ });
+ return;
+ }
+ }
+
+ // Validar fecha en tiempo real
+ if (name === "deadline" && value && deadlineConstraints.min) {
+ const [year, month, day] = value.split('-').map(Number);
+ const selectedDate = new Date(year, month - 1, day);
+
+ const [minYear, minMonth, minDay] = deadlineConstraints.min.split('-').map(Number);
+ const minDate = new Date(minYear, minMonth - 1, minDay);
+
+ const [maxYear, maxMonth, maxDay] = deadlineConstraints.max.split('-').map(Number);
+ const maxDate = new Date(maxYear, maxMonth - 1, maxDay);
+
+ if (selectedDate < minDate) {
+ Alerts.warning(
+ `Fecha demasiado pronto. La fecha mínima es ${minDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}`
+ );
+ } else if (selectedDate > maxDate) {
+ Alerts.warning(
+ `Fecha demasiado lejana. La fecha máxima es ${maxDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}`
+ );
+ } else {
+ Alerts.success('Fecha válida seleccionada');
+ }
+ }
+
+ // Actualizar campo en el estado
+ setForm({
+ ...form,
+ [name]: value,
+ });
+ };
+
+ // Validar formulario antes de enviar
+ const validateForm = () => {
+ const errors = {};
+
+ if (form.projectType.length === 0) {
+ errors.projectType = "Selecciona al menos un tipo de proyecto";
+ }
+
+ if (form.faculty.length === 0) {
+ errors.faculty = "Selecciona una facultad";
+ }
+
+ if (form.problemType.length === 0) {
+ errors.problemType = "Selecciona al menos un tipo de problemática";
+ }
+
+ if (form.problemType.includes("otro") && !form.problemTypeOther?.trim()) {
+ errors.problemTypeOther = "Por favor describe la problemática personalizada";
+ }
+
+ if (!form.deadline) {
+ errors.deadline = "La fecha de vigencia es obligatoria";
+ } else if (deadlineConstraints.min && deadlineConstraints.max) {
+ const [year, month, day] = form.deadline.split('-').map(Number);
+ const selectedDate = new Date(year, month - 1, day);
+
+ const [minYear, minMonth, minDay] = deadlineConstraints.min.split('-').map(Number);
+ const minDate = new Date(minYear, minMonth - 1, minDay);
+
+ const [maxYear, maxMonth, maxDay] = deadlineConstraints.max.split('-').map(Number);
+ const maxDate = new Date(maxYear, maxMonth - 1, maxDay);
+
+ if (selectedDate < minDate) {
+ errors.deadline = `Fecha demasiado pronto. La fecha mínima es ${minDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}`;
+ } else if (selectedDate > maxDate) {
+ errors.deadline = `Fecha demasiado lejana. La fecha máxima es ${maxDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })} (1 mes de margen desde la fecha original)`;
+ }
+ }
+
+ return errors;
+ };
+
+ // Guardar cambios sin aprobar (solo actualizar metadata)
+ const handleSaveOnly = async (e, applicationData) => {
+ e.preventDefault();
+
+ const errors = validateForm();
+
+ if (Object.keys(errors).length > 0) {
+ setFieldErrors(errors);
+ Alerts.warning("Por favor completa todos los campos requeridos");
+ return;
+ }
+
+ setIsLoading(true);
+ setFieldErrors({});
+
+ try {
+ const finalProblemTypes = form.problemType.filter(pt => pt !== "otro");
+
+ let finalProblemTypeOther = undefined;
+ if (form.problemType.includes("otro")) {
+ const trimmed = form.problemTypeOther?.trim();
+ if (trimmed) {
+ finalProblemTypeOther = trimmed;
+ }
+ }
+
+ const updateData = {
+ title: applicationData.title,
+ shortDescription: applicationData.shortDescription,
+ description: applicationData.detailedDescription,
+ deadline: form.deadline,
+ projectType: form.projectType,
+ faculty: form.faculty,
+ problemType: finalProblemTypes,
+ };
+
+ if (finalProblemTypeOther !== undefined) {
+ updateData.problemTypeOther = finalProblemTypeOther;
+ }
+
+ await updateApplication(uuid, updateData);
+
+ Alerts.success("Cambios guardados exitosamente");
+
+ if (onEditSuccess) {
+ onEditSuccess();
+ }
+
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ if (error.details && error.details.length > 0) {
+ const processedErrors = processFieldErrors(error.details);
+ setFieldErrors(processedErrors);
+ Alerts.error("Por favor revisa los campos marcados");
+ } else {
+ Alerts.error(getDisplayMessage(error));
+ }
+ } else {
+ Alerts.error(getDisplayMessage(error));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Guardar cambios y aprobar (crear proyecto)
+ const handleSaveAndApprove = async (e, applicationData) => {
+ e.preventDefault();
+
+ if (form.projectType.length === 0) {
+ Alerts.error("Debes seleccionar un tipo de proyecto antes de aprobar");
+ setFieldErrors(prev => ({
+ ...prev,
+ projectType: "Selecciona un tipo de proyecto"
+ }));
+ return;
+ }
+
+ if (!deadlineConstraints.min) {
+ Alerts.error("Espera a que se calculen las fechas permitidas. Si el problema persiste, recarga la página.");
+ return;
+ }
+
+ const errors = validateForm();
+
+ if (Object.keys(errors).length > 0) {
+ setFieldErrors(errors);
+
+ if (errors.deadline) {
+ Alerts.error(`Fecha inválida: ${errors.deadline}`);
+ } else {
+ Alerts.warning("Por favor completa todos los campos requeridos");
+ }
+ return;
+ }
+
+ setIsLoading(true);
+ setFieldErrors({});
+
+ try {
+ const finalProblemTypes = form.problemType.filter(pt => pt !== "otro");
+
+ let finalProblemTypeOther = undefined;
+ if (form.problemType.includes("otro")) {
+ const trimmed = form.problemTypeOther?.trim();
+ if (trimmed) {
+ finalProblemTypeOther = trimmed;
+ }
+ }
+
+ const projectData = {
+ title: applicationData.title,
+ shortDescription: applicationData.shortDescription,
+ description: applicationData.detailedDescription,
+ deadline: form.deadline,
+ projectType: form.projectType,
+ faculty: form.faculty,
+ problemType: finalProblemTypes,
+ };
+
+ if (finalProblemTypeOther !== undefined) {
+ projectData.problemTypeOther = finalProblemTypeOther;
+ }
+
+ const response = await approveApplication(uuid, projectData);
+
+ const projectUuid = response?.project?.uuid_project;
+
+ if (!projectUuid) {
+ throw new Error("No se pudo obtener el UUID del proyecto creado");
+ }
+
+ Alerts.success("Proyecto aprobado exitosamente");
+
+ if (onApproveSuccess) {
+ onApproveSuccess(projectUuid);
+ }
+
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ if (error.details && error.details.length > 0) {
+ const processedErrors = processFieldErrors(error.details);
+ setFieldErrors(processedErrors);
+ Alerts.error("Por favor revisa los campos marcados");
+ } else {
+ Alerts.error(getDisplayMessage(error));
+ }
+ } else {
+ Alerts.error(getDisplayMessage(error));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Resetear formulario a estado inicial
+ const resetForm = () => {
+ setForm({
+ projectType: [],
+ faculty: [],
+ problemType: [],
+ problemTypeOther: "",
+ deadline: "",
+ });
+ setFieldErrors({});
+ };
+
+ return {
+ form,
+ fieldErrors,
+ isLoading,
+ handleChange,
+ handleSaveOnly,
+ handleSaveAndApprove,
+ resetForm,
+ initializeForm,
+ deadlineConstraints,
+ };
+}
diff --git a/apps/web/src/features/projects/hooks/useProjectActions.js b/apps/web/src/features/projects/hooks/useProjectActions.js
new file mode 100644
index 00000000..4fe9764d
--- /dev/null
+++ b/apps/web/src/features/projects/hooks/useProjectActions.js
@@ -0,0 +1,91 @@
+import { useState } from 'react';
+import { startProject, rollbackProject, updateProjectDeadline } from '../projectsService';
+import { Alerts } from '@/shared/alerts';
+import { getDisplayMessage } from '@/utils/errorHandler';
+
+/**
+ * Hook para acciones del proyecto: start, rollback, updateDeadline
+ */
+export default function useProjectActions(projectUuid) {
+ const [isStarting, setIsStarting] = useState(false);
+ const [isRollingBack, setIsRollingBack] = useState(false);
+ const [isUpdatingDeadline, setIsUpdatingDeadline] = useState(false);
+
+ /**
+ * Inicia el proyecto
+ */
+ const handleStart = async () => {
+ setIsStarting(true);
+ try {
+ await startProject(projectUuid);
+ Alerts.success('Proyecto iniciado exitosamente');
+ return true;
+ } catch (err) {
+ console.error('Error starting project:', err);
+ const errorMessage = getDisplayMessage(err);
+ Alerts.error(errorMessage);
+ return false;
+ } finally {
+ setIsStarting(false);
+ }
+ };
+
+ /**
+ * Hace rollback del proyecto
+ */
+ const handleRollback = async () => {
+ setIsRollingBack(true);
+ try {
+ await rollbackProject(projectUuid);
+ Alerts.success('Proyecto revertido exitosamente');
+ return true;
+ } catch (err) {
+ console.error('Error rolling back project:', err);
+ const errorMessage = getDisplayMessage(err);
+
+ // Diferenciar error 403 de "no eres el creador" vs "no tienes rol"
+ if (err.statusCode === 403) {
+ if (errorMessage.toLowerCase().includes('creator') ||
+ errorMessage.toLowerCase().includes('creador')) {
+ Alerts.error('Solo el profesor que aprobó este proyecto puede revertirlo');
+ } else {
+ Alerts.error(errorMessage);
+ }
+ } else {
+ Alerts.error(errorMessage);
+ }
+
+ return false;
+ } finally {
+ setIsRollingBack(false);
+ }
+ };
+
+ /**
+ * Actualiza el deadline del proyecto
+ */
+ const handleUpdateDeadline = async (newDeadline) => {
+ setIsUpdatingDeadline(true);
+ try {
+ await updateProjectDeadline(projectUuid, newDeadline);
+ Alerts.success('Fecha límite actualizada exitosamente');
+ return true;
+ } catch (err) {
+ console.error('Error updating deadline:', err);
+ const errorMessage = getDisplayMessage(err);
+ Alerts.error(errorMessage);
+ return false;
+ } finally {
+ setIsUpdatingDeadline(false);
+ }
+ };
+
+ return {
+ isStarting,
+ isRollingBack,
+ isUpdatingDeadline,
+ handleStart,
+ handleRollback,
+ handleUpdateDeadline
+ };
+}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/hooks/useProjectDetails.js b/apps/web/src/features/projects/hooks/useProjectDetails.js
index 8bfc656d..adb26875 100644
--- a/apps/web/src/features/projects/hooks/useProjectDetails.js
+++ b/apps/web/src/features/projects/hooks/useProjectDetails.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { getProjectDetails } from "../projectsService";
import { Alerts } from "@/shared/alerts";
import { getDisplayMessage, AuthenticationError } from "@/utils/errorHandler";
@@ -6,48 +6,48 @@ import { getDisplayMessage, AuthenticationError } from "@/utils/errorHandler";
/**
* Hook para obtener los detalles de un proyecto específico
* @param {string} uuid - UUID del proyecto
- * @returns {Object} { project, isLoading, error }
+ * @returns {Object} { project, isLoading, error, refetch }
*/
export default function useProjectDetails(uuid) {
const [project, setProject] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- useEffect(() => {
+ const fetchProjectDetails = useCallback(async () => {
if (!uuid) {
setError("UUID del proyecto no proporcionado");
setIsLoading(false);
return;
}
- const fetchProjectDetails = async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const data = await getProjectDetails(uuid);
- setProject(data);
- } catch (err) {
- console.error("Error fetching project details:", err);
-
- if (err instanceof AuthenticationError) {
- Alerts.error("Sesión expirada. Redirigiendo al login...");
- setTimeout(() => {
- window.location.href = "/login";
- }, 1500);
- return;
- }
-
- const errorMessage = getDisplayMessage(err);
- setError(errorMessage);
- Alerts.error(errorMessage);
- } finally {
- setIsLoading(false);
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const data = await getProjectDetails(uuid);
+ setProject(data);
+ } catch (err) {
+ console.error("Error fetching project details:", err);
+
+ if (err instanceof AuthenticationError) {
+ Alerts.error("Sesión expirada. Redirigiendo al login...");
+ setTimeout(() => {
+ window.location.href = "/login";
+ }, 1500);
+ return;
}
- };
- fetchProjectDetails();
+ const errorMessage = getDisplayMessage(err);
+ setError(errorMessage);
+ Alerts.error(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
}, [uuid]);
- return { project, isLoading, error };
+ useEffect(() => {
+ fetchProjectDetails();
+ }, [fetchProjectDetails]);
+
+ return { project, isLoading, error, refetch: fetchProjectDetails };
}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/hooks/useProjectValidation.js b/apps/web/src/features/projects/hooks/useProjectValidation.js
new file mode 100644
index 00000000..43b48ad5
--- /dev/null
+++ b/apps/web/src/features/projects/hooks/useProjectValidation.js
@@ -0,0 +1,116 @@
+import { useMemo } from 'react';
+import { parseDateLocal } from '@/utils/dateUtils';
+
+/**
+ * Hook para validar si un proyecto puede ser iniciado
+ * Verifica:
+ * 1. Equipo completo según restricciones del tipo de proyecto
+ * 2. Deadline dentro del rango válido
+ */
+export default function useProjectValidation(project, constraints = {}, teamMembers = null) {
+
+ const validation = useMemo(() => {
+ if (!project) {
+ return {
+ canStart: false,
+ teamValid: false,
+ deadlineValid: false,
+ errors: [],
+ missingRoles: []
+ };
+ }
+
+ const errors = [];
+ const missingRoles = [];
+ const projectType = project.projectTypes?.[0];
+ // Usar teamMembers pasados o los del proyecto
+ const members = teamMembers || project.teamMembers || [];
+ const createdAt = project.createdAt;
+ const deadline = project.estimatedDate;
+
+ // VALIDACIÓN DE EQUIPO
+ let teamValid = false;
+
+ // Si tenemos restricciones, validamos contra ellas
+ if (Object.keys(constraints).length > 0) {
+ // Contar miembros por rol
+ const roleCounts = {};
+ members.forEach(member => {
+ const roleName = member.roleName || member.role; // Soporta ambos formatos
+ roleCounts[roleName] = (roleCounts[roleName] || 0) + 1;
+ });
+
+ let allConstraintsMet = true;
+
+ // Verificar cada restricción
+ for (const [roleName, constraint] of Object.entries(constraints)) {
+ const count = roleCounts[roleName] || 0;
+ const min = constraint.min;
+ const max = constraint.max;
+
+ // Verificar mínimo
+ if (min > 0 && count < min) {
+ allConstraintsMet = false;
+ const needed = min - count;
+ missingRoles.push({
+ role: roleName,
+ current: count,
+ min: min,
+ max: max === Infinity ? '∞' : max,
+ needed: needed,
+ message: `Faltan ${needed} ${roleName}(s) (mínimo: ${min})`
+ });
+ }
+
+ // Verificar máximo
+ if (max !== Infinity && count > max) {
+ allConstraintsMet = false;
+ errors.push(`Demasiados ${roleName}s (máximo: ${max}, actual: ${count})`);
+ }
+ }
+
+ teamValid = allConstraintsMet;
+
+ if (!teamValid && missingRoles.length > 0) {
+ errors.push('El equipo no cumple con las restricciones del tipo de proyecto');
+ }
+ } else {
+ // Si no hay restricciones, solo verificamos que haya al menos un miembro
+ teamValid = members.length > 0;
+ if (!teamValid) {
+ errors.push('El equipo debe tener al menos un miembro');
+ }
+ }
+
+ // VALIDACIÓN DE DEADLINE
+ let deadlineValid = false;
+ if (createdAt && deadline && projectType) {
+ const createdDate = parseDateLocal(createdAt.split('T')[0]);
+ const deadlineDate = parseDateLocal(deadline.split('T')[0]);
+
+ const monthsDiff = (deadlineDate.getFullYear() - createdDate.getFullYear()) * 12
+ + (deadlineDate.getMonth() - createdDate.getMonth());
+
+ const minMonths = projectType.minEstimatedMonths || 0;
+ const maxMonths = projectType.maxEstimatedMonths || Infinity;
+
+ deadlineValid = monthsDiff >= minMonths && monthsDiff <= maxMonths;
+
+ if (!deadlineValid) {
+ errors.push(`La fecha límite debe estar entre ${minMonths} y ${maxMonths} meses desde la creación`);
+ }
+ }
+
+ const canStart = teamValid && deadlineValid;
+
+ return {
+ canStart,
+ teamValid,
+ deadlineValid,
+ errors,
+ missingRoles
+ };
+ }, [project, constraints, teamMembers]);
+
+ return validation;
+}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/hooks/useRequestProject.js b/apps/web/src/features/projects/hooks/useRequestProject.js
index 2fcbb58c..ebeb2015 100644
--- a/apps/web/src/features/projects/hooks/useRequestProject.js
+++ b/apps/web/src/features/projects/hooks/useRequestProject.js
@@ -1,11 +1,13 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { createApplication } from "../projectsService.js";
import { ValidationError, processFieldErrors, getDisplayMessage } from "@/utils/errorHandler";
import { Alerts } from "@/shared/alerts";
+import useFormProjectMetadata from "./useFormProjectMetadata.js";
export default function useRequestProject() {
const navigate = useNavigate();
+ const { projectTypes } = useFormProjectMetadata();
const [fieldErrors, setFieldErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
@@ -24,6 +26,64 @@ export default function useRequestProject() {
attachments: [],
});
+ const [deadlineConstraints, setDeadlineConstraints] = useState({
+ min: null,
+ max: null,
+ projectTypeName: null,
+ minMonths: 0,
+ maxMonths: 0,
+ });
+
+ // Helper para formatear fecha local sin zona horaria
+ const formatDateLocal = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // Calcular constraints de fecha cuando cambie el tipo de proyecto
+ useEffect(() => {
+ if (form.projectType.length > 0 && projectTypes.length > 0) {
+ const selectedTypeId = Number(form.projectType[0]);
+ const projectType = projectTypes.find(pt => pt.project_type_id === selectedTypeId);
+
+ if (projectType) {
+ // Fecha actual en zona horaria local
+ const today = new Date();
+ const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+
+ const minMonths = projectType.minEstimatedMonths || 0;
+ const maxMonths = projectType.maxEstimatedMonths || 24;
+
+ // Calcular fecha mínima (hoy + minMonths)
+ const minDate = new Date(todayLocal);
+ minDate.setMonth(minDate.getMonth() + minMonths);
+
+ // Calcular fecha máxima (hoy + maxMonths + 1 mes de buffer)
+ const maxDate = new Date(todayLocal);
+ maxDate.setMonth(maxDate.getMonth() + maxMonths + 1);
+
+ setDeadlineConstraints({
+ min: formatDateLocal(minDate),
+ max: formatDateLocal(maxDate),
+ projectTypeName: projectType.name,
+ minMonths,
+ maxMonths: maxMonths + 1
+ });
+ }
+ } else {
+ setDeadlineConstraints({
+ min: null,
+ max: null,
+ projectTypeName: null,
+ minMonths: 0,
+ maxMonths: 0
+ });
+ }
+ }, [form.projectType, projectTypes]);
+
+ // Manejar cambios en los campos del formulario
const handleChange = (e) => {
const { name, value, type, checked, files } = e.target;
@@ -36,27 +96,34 @@ export default function useRequestProject() {
});
}
- // MANEJO DE CHECKBOXES (projectType, faculty, problemType)
+ // Manejo de radio button para faculty
+ if (type === "radio" && name === "faculty") {
+ setForm(prevForm => ({
+ ...prevForm,
+ faculty: [String(value)]
+ }));
+ return;
+ }
+
+ // Manejo de checkboxes (projectType, problemType)
if (type === "checkbox") {
- if (name === "projectType" || name === "faculty" || name === "problemType") {
+ if (name === "projectType" || name === "problemType") {
setForm(prevForm => {
const currentArray = prevForm[name] || [];
if (checked) {
- // Agregar valor si está checkeado
return {
...prevForm,
[name]: [...currentArray, value]
};
} else {
- // Remover valor si está desmarcado
return {
...prevForm,
[name]: currentArray.filter(item => item !== value)
};
}
});
- return; // salir de la función aquí
+ return;
}
}
@@ -66,7 +133,7 @@ export default function useRequestProject() {
...form,
customBannerFile: files[0],
customBannerName: files[0].name,
- selectedBannerUuid: "", // Limpiar UUID si se sube archivo
+ selectedBannerUuid: "",
});
return;
}
@@ -78,7 +145,43 @@ export default function useRequestProject() {
attachments: Array.from(files),
});
return;
- }
+ }
+
+ // Validar fecha en tiempo real
+ if (name === "deadline" && value && deadlineConstraints.min) {
+ const [year, month, day] = value.split('-').map(Number);
+ const selectedDate = new Date(year, month - 1, day);
+
+ const [minYear, minMonth, minDay] = deadlineConstraints.min.split('-').map(Number);
+ const minDate = new Date(minYear, minMonth - 1, minDay);
+
+ const [maxYear, maxMonth, maxDay] = deadlineConstraints.max.split('-').map(Number);
+ const maxDate = new Date(maxYear, maxMonth - 1, maxDay);
+
+ const today = new Date();
+ const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+
+ // Calcular meses desde hoy
+ const monthsDiff = Math.round(
+ (selectedDate.getFullYear() - todayLocal.getFullYear()) * 12 +
+ (selectedDate.getMonth() - todayLocal.getMonth()) +
+ (selectedDate.getDate() - todayLocal.getDate()) / 30
+ );
+
+ if (selectedDate < minDate) {
+ Alerts.warning(
+ `Fecha demasiado pronto. El proyecto debe durar al menos ${deadlineConstraints.minMonths} meses desde hoy`
+ );
+ } else if (selectedDate > maxDate) {
+ Alerts.warning(
+ `Fecha demasiado lejana. No puede superar ${deadlineConstraints.maxMonths} meses desde hoy`
+ );
+ } else {
+ Alerts.success(
+ `Fecha válida: ${monthsDiff} meses desde hoy`
+ );
+ }
+ }
// Campos de texto normales
setForm({
@@ -87,34 +190,7 @@ export default function useRequestProject() {
});
};
- // Manejo de banner predefinido (llamado desde RequestProjectForm)
- const handleBannerSelection = (uuid) => {
- setForm({
- ...form,
- selectedBannerUuid: uuid,
- customBannerFile: null,
- customBannerName: "",
- });
-
- // Limpiar error de banner
- if (fieldErrors.banner) {
- setFieldErrors(prev => {
- const newErrors = { ...prev };
- delete newErrors.banner;
- return newErrors;
- });
- }
- };
-
- // Remover archivo adjunto (llamado desde RequestProjectForm)
- const handleRemoveAttachment = (index) => {
- setForm(prevForm => ({
- ...prevForm,
- attachments: prevForm.attachments.filter((_, i) => i !== index),
- }));
- };
-
- // Validación del formulario
+ // Validar formulario antes de enviar
const validateForm = () => {
const errors = {};
@@ -131,23 +207,78 @@ export default function useRequestProject() {
}
if (!form.deadline) {
- errors.deadline = "La fecha límite es requerida";
+ errors.deadline = "La fecha de vigencia es obligatoria";
+ } else if (deadlineConstraints.min && deadlineConstraints.max) {
+ const [year, month, day] = form.deadline.split('-').map(Number);
+ const selectedDate = new Date(year, month - 1, day);
+
+ const [minYear, minMonth, minDay] = deadlineConstraints.min.split('-').map(Number);
+ const minDate = new Date(minYear, minMonth - 1, minDay);
+
+ const [maxYear, maxMonth, maxDay] = deadlineConstraints.max.split('-').map(Number);
+ const maxDate = new Date(maxYear, maxMonth - 1, maxDay);
+
+ const today = new Date();
+ const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+
+ const monthsDiff = Math.round(
+ (selectedDate.getFullYear() - todayLocal.getFullYear()) * 12 +
+ (selectedDate.getMonth() - todayLocal.getMonth()) +
+ (selectedDate.getDate() - todayLocal.getDate()) / 30
+ );
+
+ if (selectedDate < minDate) {
+ errors.deadline = `Fecha demasiado pronto. El proyecto debe durar al menos ${deadlineConstraints.minMonths} meses desde hoy. Fecha mínima: ${minDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}`;
+ } else if (selectedDate > maxDate) {
+ errors.deadline = `Fecha demasiado lejana. No puede superar ${deadlineConstraints.maxMonths} meses desde hoy (incluyendo 1 mes de margen). Fecha máxima: ${maxDate.toLocaleDateString('es-MX', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}`;
+ }
}
if (!form.selectedBannerUuid && !form.customBannerFile) {
errors.banner = "Debes seleccionar o subir un banner";
}
- // Validar que los arrays tengan al menos un elemento (si son requeridos)
- // Según el formulario, estos son opcionales, así que no validamos
-
return errors;
};
+ // Seleccionar banner predeterminado
+ const handleBannerSelection = (uuid) => {
+ setForm({
+ ...form,
+ selectedBannerUuid: uuid,
+ customBannerFile: null,
+ customBannerName: "",
+ });
+
+ if (fieldErrors.banner) {
+ setFieldErrors(prev => {
+ const newErrors = { ...prev };
+ delete newErrors.banner;
+ return newErrors;
+ });
+ }
+ };
+
+ // Remover archivo adjunto por índice
+ const handleRemoveAttachment = (index) => {
+ setForm(prevForm => ({
+ ...prevForm,
+ attachments: prevForm.attachments.filter((_, i) => i !== index),
+ }));
+ };
+
+ // Enviar formulario de solicitud
const handleSubmit = async (e) => {
e.preventDefault();
- // Validar formulario
const errors = validateForm();
if (Object.keys(errors).length > 0) {
@@ -160,24 +291,19 @@ export default function useRequestProject() {
setFieldErrors({});
try {
- // Construir FormData
const formData = new FormData();
- // Campos básicos
formData.append("title", form.title);
formData.append("shortDescription", form.shortDescription);
formData.append("description", form.description);
formData.append("deadline", form.deadline);
- // Banner (UUID o archivo, exclusivo)
if (form.selectedBannerUuid) {
formData.append("selectedBannerUuid", form.selectedBannerUuid);
} else if (form.customBannerFile) {
formData.append("customBannerFile", form.customBannerFile);
}
- // Enviar cada ID individualmente con []
- // Solo enviar si hay elementos
if (form.projectType && form.projectType.length > 0) {
form.projectType.forEach(id => {
formData.append("projectType[]", id);
@@ -196,24 +322,17 @@ export default function useRequestProject() {
});
}
- // Tipo de problemática "Otro"
if (form.problemTypeOther?.trim()) {
formData.append("problemTypeOther", form.problemTypeOther);
}
- // Archivos adjuntos
if (form.attachments && form.attachments.length > 0) {
form.attachments.forEach((file) => {
formData.append("attachments", file);
});
}
- for (let [key, value] of formData.entries()) {
- console.log(` ${key}:`, value instanceof File ? `File: ${value.name}` : value);
- }
-
- // Enviar al backend
- const response = await createApplication(formData);
+ await createApplication(formData);
Alerts.success("¡Tu proyecto ha sido enviado correctamente!");
@@ -222,8 +341,6 @@ export default function useRequestProject() {
}, 2000);
} catch (error) {
-
- // Manejo de errores de validación
if (error instanceof ValidationError) {
if (error.details && error.details.length > 0) {
const processedErrors = processFieldErrors(error.details);
@@ -233,9 +350,7 @@ export default function useRequestProject() {
} else {
Alerts.error(getDisplayMessage(error));
}
- }
- // Otros errores
- else {
+ } else {
Alerts.error(getDisplayMessage(error));
}
} finally {
@@ -251,5 +366,6 @@ export default function useRequestProject() {
handleBannerSelection,
handleRemoveAttachment,
handleSubmit,
+ deadlineConstraints,
};
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/features/projects/pages/ApplicationDetails.jsx b/apps/web/src/features/projects/pages/ApplicationDetails.jsx
index 13d281d5..5ed05e4a 100644
--- a/apps/web/src/features/projects/pages/ApplicationDetails.jsx
+++ b/apps/web/src/features/projects/pages/ApplicationDetails.jsx
@@ -4,29 +4,39 @@ import ProjectImage from '../components/ProjectImage';
import ProjectSummary from '../components/ProjectSummary';
import ProjectInfoCard from '../components/ProjectInfoCard';
import AttachmentCard from '../components/AttachmentCard';
+import ProjectStatusBadge from '../components/ProjectStatusBadge';
+import EditApplicationModal from '../components/EditApplicationModal';
import useApplicationDetails from '../hooks/useApplicationDetails';
import { downloadAllAttachments, approveApplication } from '../projectsService';
import { Alerts } from '@/shared/alerts';
+import { formatDateStringSpanish } from '@/utils/dateUtils';
export default function ApplicationDetails() {
const { uuid } = useParams();
const navigate = useNavigate();
- const { application, isLoading, error } = useApplicationDetails(uuid);
+ const { application, isLoading, error, refetch } = useApplicationDetails(uuid);
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [downloadError, setDownloadError] = useState(null);
const [isApproving, setIsApproving] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
- // Formatear fechas
- const formatDate = (dateString) => {
- if (!dateString) return 'No especificada';
- return new Date(dateString).toLocaleDateString('es-MX', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
+ const handleEditClick = () => {
+ setIsEditModalOpen(true);
+ };
+
+ const handleEditSuccess = () => {
+ refetch();
+ Alerts.success("Cambios guardados exitosamente");
};
- // Función para descargar todos los archivos
+ const handleApproveSuccess = () => {
+ Alerts.success('¡Proyecto aprobado exitosamente!');
+ setTimeout(() => {
+ navigate(`/my-projects/`);
+ }, 1500);
+ };
+
+ // Descargar todos los archivos adjuntos
const handleDownloadAll = async () => {
if (!application?.attachments || application.attachments.length === 0) {
alert('No hay archivos para descargar');
@@ -50,17 +60,17 @@ export default function ApplicationDetails() {
} finally {
setIsDownloadingAll(false);
}
- };
+ };
+ // Aprobar proyecto sin modificaciones
const handleApprove = async () => {
const result = await Alerts.confirm({
title: '¿Aprobar este proyecto?',
- text: 'Esta acción creará un nuevo proyecto activo basado en esta solicitud. El proyecto quedará disponible en "Mis Proyectos".',
+ text: 'Esta acción creará un nuevo proyecto activo basado en esta solicitud SIN modificaciones.',
confirmText: 'Sí, aprobar',
cancelText: 'Cancelar',
});
- // Si el usuario cancela, salir
if (!result.isConfirmed) {
return;
}
@@ -68,12 +78,10 @@ export default function ApplicationDetails() {
setIsApproving(true);
try {
- // Extraer IDs directamente del backend
const projectTypeIds = application.projectTypes.map(pt => pt.id);
const facultyIds = application.faculties.map(f => f.id);
const problemTypeIds = application.problemTypes.map(pt => pt.id);
- // Validaciones
if (projectTypeIds.length === 0) {
Alerts.warning('El proyecto debe tener al menos un tipo de proyecto');
setIsApproving(false);
@@ -100,34 +108,31 @@ export default function ApplicationDetails() {
problemType: problemTypeIds,
};
- // Mostrar loading mientras se aprueba
const loadingAlert = Alerts.loading('Aprobando proyecto...');
try {
const response = await approveApplication(uuid, projectData);
- // Cerrar loading
loadingAlert.close();
- console.log('Proyecto aprobado:', response);
+ const projectUuid = response?.project?.uuid_project;
+
+ if (!projectUuid) {
+ throw new Error("No se pudo obtener el UUID del proyecto creado");
+ }
- // Mostrar éxito
Alerts.success('¡Proyecto aprobado exitosamente!');
- // Redirigir después de 1.5 segundos
setTimeout(() => {
- navigate('/my-projects');
+ navigate(`/my-projects/`);
}, 1500);
} catch (error) {
- // Cerrar loading en caso de error
loadingAlert.close();
- throw error; // Re-lanzar para el catch externo
+ throw error;
}
} catch (error) {
- console.error('Error al aprobar proyecto:', error);
-
if (error.message) {
Alerts.error(error.message);
} else {
@@ -203,6 +208,7 @@ export default function ApplicationDetails() {
);
}
+ // Extraer datos de la aplicación
const {
title,
shortDescription,
@@ -217,6 +223,7 @@ export default function ApplicationDetails() {
createdAt,
dueDate,
attachments = [],
+ projectUuid,
} = application;
const authorFirstName = author?.firstName || 'No especificado';
@@ -232,6 +239,7 @@ export default function ApplicationDetails() {
const isOutsider = !!outsiderData;
const authorRole = isOutsider ? 'Outsider' : 'Profesor';
+ // Información del solicitante
const applicantInfo = [
{
label: 'Nombre del solicitante',
@@ -262,12 +270,12 @@ export default function ApplicationDetails() {
]),
];
- // Preparar información del proyecto
+ // Información del proyecto
const projectInfo = [
{
label: 'Tipo de proyecto',
value: projectTypes.length > 0
- ? projectTypes.map(pt => pt.name).join(', ') // Extraer .name
+ ? projectTypes.map(pt => pt.name).join(', ')
: 'No especificado'
},
{
@@ -284,11 +292,15 @@ export default function ApplicationDetails() {
},
{
label: 'Fecha límite',
- value: formatDate(dueDate)
+ value: dueDate
+ ? formatDateStringSpanish(dueDate.split('T')[0])
+ : 'No especificada'
},
{
label: 'Fecha de creación',
- value: formatDate(createdAt)
+ value: createdAt
+ ? formatDateStringSpanish(createdAt.split('T')[0])
+ : 'No especificada'
},
{
label: 'Estado',
@@ -300,28 +312,42 @@ export default function ApplicationDetails() {
},
];
- const handleContact = () => {
- if (authorEmail) {
- window.location.href = `mailto:${authorEmail}?subject=Interés en el proyecto: ${title}`;
- } else {
- alert('No hay correo de contacto disponible para este proyecto');
- }
- };
-
const isAlreadyApproved = application?.status === 'approved';
return (
- {/* Botón de regreso */}
-
+ {/* Header con estado y redirección */}
+
+
+
+
+ {/* Badge de estado */}
+ {application?.status && (
+
+ )}
+
+ {/* Botón para ver proyecto aprobado */}
+ {application?.projectUuid && (
+
+ )}
+
+
Detalles del proyecto
@@ -330,19 +356,16 @@ export default function ApplicationDetails() {
{/* Columna izquierda: Imagen, descripción y archivos adjuntos */}
- {/* Banner */}
- {/* Resumen del proyecto */}
- {/* Archivos adjuntos */}
{attachments.length > 0 && (
@@ -358,23 +381,97 @@ export default function ApplicationDetails() {
)}
- {/* Columna derecha: Información */}
+ {/* Columna derecha: Información y acciones */}
+ {/* Información de la Solicitud */}
+
+
+ Información de la Solicitud
+
+
+ {/* Estado actual */}
+ {application?.status && (
+
+ )}
+
+ {/* Resto de campos existentes */}
+ {projectTypes?.length > 0 && (
+
+
Tipo de proyecto:
+
+ {projectTypes.map(pt => pt.name).join(', ')}
+
+
+ )}
+
+ {faculties?.length > 0 && (
+
+
Facultades:
+
+ {faculties.map(f => f.name).join(', ')}
+
+
+ )}
+
+ {problemTypes?.length > 0 && (
+
+
Tipo de problemática:
+
+ {problemTypes.map(pt => pt.name).join(', ')}
+
+
+ )}
+
+ {dueDate && (
+
+
Fecha límite:
+
+ {formatDateStringSpanish(dueDate.split('T')[0])}
+
+
+ )}
+
+ {createdAt && (
+
+
Fecha de creación:
+
+ {formatDateStringSpanish(createdAt.split('T')[0])}
+
+
+ )}
+
+
+
{/* Información del solicitante */}
Información del solicitante
- {/* Información del proyecto */}
-
- Información del proyecto
-
-
-
{/* Botones de acción */}
+ {/* Primera fila: Editar + Aceptar */}
+ {/* Botón Editar proyecto */}
+
+
{/* Botón Aceptar Proyecto */}
-
- {/* Botón Descargar Todos los Archivos */}
-
+
+ {/* Segunda fila: Descargar todos */}
+
{/* Mensaje de error si ocurrió */}
{downloadError && (
@@ -443,19 +540,10 @@ export default function ApplicationDetails() {
{downloadError}
)}
-
- {/* Botón Ponerse en Contacto */}
-
+ {/* Mensaje informativo si ya está aprobado */}
{isAlreadyApproved && (
-
+
@@ -471,6 +559,16 @@ export default function ApplicationDetails() {
+
+ {/* Modal de edición y aprobación */}
+
setIsEditModalOpen(false)}
+ uuid={uuid}
+ application={application}
+ onEditSuccess={handleEditSuccess}
+ onApproveSuccess={handleApproveSuccess}
+ />
);
}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/pages/MyApplicationDetails.jsx b/apps/web/src/features/projects/pages/MyApplicationDetails.jsx
index 9a09351e..89590064 100644
--- a/apps/web/src/features/projects/pages/MyApplicationDetails.jsx
+++ b/apps/web/src/features/projects/pages/MyApplicationDetails.jsx
@@ -6,6 +6,7 @@ import ProjectInfoCard from '../components/ProjectInfoCard';
import AttachmentCard from '../components/AttachmentCard';
import useApplicationDetails from '../hooks/useApplicationDetails';
import { downloadAllAttachments } from '../projectsService';
+import { formatDateStringSpanish } from '@/utils/dateUtils';
export default function MyApplicationDetails() {
const { uuid } = useParams();
@@ -14,17 +15,7 @@ export default function MyApplicationDetails() {
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [downloadError, setDownloadError] = useState(null);
- // Formatear fechas
- const formatDate = (dateString) => {
- if (!dateString) return 'No especificada';
- return new Date(dateString).toLocaleDateString('es-MX', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- };
-
- // Función para descargar todos los archivos
+ // Descargar todos los archivos adjuntos
const handleDownloadAll = async () => {
if (!application?.attachments || application.attachments.length === 0) {
alert('No hay archivos para descargar');
@@ -131,11 +122,11 @@ export default function MyApplicationDetails() {
project,
} = application;
- // Determinar el estado visual
+ // Configuración de estados visuales
const statusConfig = {
pending: {
label: 'Pendiente de revisión',
- color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
+ color: 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700',
icon: (
- {/* Columna derecha: Información */}
+ {/* Columna derecha: Información y acciones */}
{/* Información del proyecto */}
@@ -268,22 +265,22 @@ export default function MyApplicationDetails() {
{/* Si fue aprobada, mostrar info del proyecto creado */}
- {status === 'approved' && project && (
-
+ {statusSlug === 'approved' && project && (
+
-
+
-
+
¡Tu solicitud fue aprobada!
-
- Esta solicitud se convirtió en un proyecto activo el {formatDate(project.createdAt)}
+
+ Esta solicitud se convirtió en un proyecto activo el {formatDateStringSpanish(project.createdAt.split('T')[0])}
@@ -327,7 +324,7 @@ export default function MyApplicationDetails() {
)}
{/* Botón Ponerse en Contacto (solo si está pendiente o rechazada) */}
- {status !== 'approved' && (
+ {/* {statusSlug !== 'approved' && (
- )}
+ )} */}
{/* Nota informativa según estado */}
- {status === 'pending' && (
+ {statusSlug === 'pending' && (
@@ -352,7 +349,7 @@ export default function MyApplicationDetails() {
)}
- {status === 'rejected' && (
+ {statusSlug === 'rejected' && (
@@ -370,4 +367,4 @@ export default function MyApplicationDetails() {
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/features/projects/pages/MyApplications.jsx b/apps/web/src/features/projects/pages/MyApplications.jsx
index f1102fcc..3f0bb937 100644
--- a/apps/web/src/features/projects/pages/MyApplications.jsx
+++ b/apps/web/src/features/projects/pages/MyApplications.jsx
@@ -117,6 +117,7 @@ export default function MyApplications() {
image={app.bannerUrl}
title={app.title}
description={app.shortDescription}
+ status={app.status}
onDetailsClick={() => handleApplicationClick(app.uuid_application)}
/>
))}
diff --git a/apps/web/src/features/projects/pages/ProjectDetails.jsx b/apps/web/src/features/projects/pages/ProjectDetails.jsx
index 690b11ff..df6b1940 100644
--- a/apps/web/src/features/projects/pages/ProjectDetails.jsx
+++ b/apps/web/src/features/projects/pages/ProjectDetails.jsx
@@ -5,26 +5,45 @@ import ProjectSummary from '../components/ProjectSummary';
import ProjectInfoCard from '../components/ProjectInfoCard';
import AttachmentCard from '../components/AttachmentCard';
import useProjectDetails from '../hooks/useProjectDetails';
+import useProjectActions from '@/features/projects/hooks/useProjectActions';
+import useProjectValidation from '@/features/projects/hooks/useProjectValidation';
+import useTeamMetadata from '@/features/teams/hooks/useTeamMetadata';
+import ProjectStatusBadge from '../components/ProjectStatusBadge';
+import StartProjectModal from '../components/StartProjectModal';
+import RollbackProjectModal from '../components/RollbackProjectModal';
+import UpdateDeadlineModal from '../components/UpdateDeadlineModal';
import { downloadAllAttachments } from '../projectsService';
+import { formatDateStringSpanish } from '@/utils/dateUtils';
export default function ProjectDetails() {
const { uuid } = useParams();
const navigate = useNavigate();
- const { project, isLoading, error } = useProjectDetails(uuid);
+ const { project, isLoading, error, refetch } = useProjectDetails(uuid);
+ const { constraints } = useTeamMetadata(uuid);
+ const validation = useProjectValidation(project, constraints);
+ const {
+ isStarting,
+ isRollingBack,
+ isUpdatingDeadline,
+ handleStart,
+ handleRollback,
+ handleUpdateDeadline
+ } = useProjectActions(uuid);
+
+ const [showStartModal, setShowStartModal] = useState(false);
+ const [showRollbackModal, setShowRollbackModal] = useState(false);
+ const [showUpdateDeadlineModal, setShowUpdateDeadlineModal] = useState(false);
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
const [downloadError, setDownloadError] = useState(null);
- // Formatear fechas
- const formatDate = (dateString) => {
- if (!dateString) return 'No especificada';
- return new Date(dateString).toLocaleDateString('es-MX', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- };
+ // Permisos
+ const isCreator = true;
+ const canStart = project?.status?.slug === 'project_approved' && isCreator;
+ const isProjectStarted = project?.status?.slug === 'project_in_progress' || project?.status?.slug === 'completed';
+ const canRollback = isCreator && (project?.status?.slug === 'project_in_progress' || project?.status?.slug === 'completed');
+ const canUpdateDeadline = isCreator && !isRollingBack && !isStarting;
- // Función para descargar todos los archivos
+ // Descargar todos los archivos adjuntos
const handleDownloadAll = async () => {
if (!project?.attachments || project.attachments.length === 0) {
alert('No hay archivos para descargar');
@@ -33,10 +52,10 @@ export default function ProjectDetails() {
setIsDownloadingAll(true);
setDownloadError(null);
-
+
try {
const result = await downloadAllAttachments(project.attachments);
-
+
if (result.failed > 0) {
const errorMessage = `Se descargaron ${result.successful} de ${project.attachments.length} archivos.\n\nErrores:\n${result.errors.join('\n')}`;
setDownloadError(errorMessage);
@@ -50,12 +69,46 @@ export default function ProjectDetails() {
}
};
+ // Handler para iniciar proyecto
+ const confirmStart = async () => {
+ const success = await handleStart();
+ if (success) {
+ setShowStartModal(false);
+ // Esperar refetch para asegurar que la UI se actualiza
+ await refetch();
+ // Forzar actualización adicional después de un pequeño delay
+ setTimeout(() => {
+ refetch();
+ }, 500);
+ }
+ };
+
+ // Handler para rollback
+ const confirmRollback = async () => {
+ const success = await handleRollback();
+ if (success) {
+ setShowRollbackModal(false);
+ navigate('/my-applications');
+ }
+ };
+
+ // Handler para actualizar deadline
+ const confirmUpdateDeadline = async (newDeadline) => {
+ const success = await handleUpdateDeadline(newDeadline);
+ if (success) {
+ setShowUpdateDeadlineModal(false);
+ await refetch();
+ setTimeout(() => {
+ refetch();
+ }, 500);
+ }
+ };
+
// Loading state
if (isLoading) {
return (
-
@@ -68,7 +121,6 @@ export default function ProjectDetails() {
-
@@ -97,7 +149,6 @@ export default function ProjectDetails() {
Volver a mis proyectos
-
@@ -128,6 +179,7 @@ export default function ProjectDetails() {
status,
createdAt,
estimatedDate,
+ teamMembers = [],
attachments = [],
} = project;
@@ -137,111 +189,74 @@ export default function ProjectDetails() {
const authorFullName = `${authorFirstName} ${authorLastName}`.trim();
const authorEmail = author?.email || null;
- // Datos específicos de outsider (pueden ser null)
- const outsiderData = author?.outsider || null;
- const organizationName = outsiderData?.organizationName || 'N/A';
- const phoneNumber = outsiderData?.phoneNumber || 'N/A';
- const location = outsiderData?.location || 'N/A';
-
- // Determinar si el autor es outsider o profesor
- const isOutsider = !!outsiderData;
- const authorRole = isOutsider ? 'Outsider' : 'Profesor';
-
// Información del autor
const authorInfo = [
- {
- label: 'Nombre',
- value: authorFullName
- },
- {
- label: 'Tipo de usuario',
- value: authorRole
+ { label: 'Nombre', value: author?.fullName || 'No especificado' },
+ { label: 'Correo', value: author?.email || 'No especificado' },
+ { label: 'Matrícula', value: author?.universityId || 'No especificada' },
+ { label: 'Tipo de usuario', value: author?.roleName === 'professor' ? 'Profesor' :
+ author?.roleName === 'student' ? 'Estudiante' :
+ author?.roleName === 'outsider' ? 'Externo' :
+ author?.roleName || 'No especificado'
},
- ...(isOutsider ? [
- {
- label: 'Organización',
- value: organizationName
- },
- {
- label: 'Teléfono de contacto',
- value: phoneNumber
- },
- {
- label: 'Ubicación',
- value: location
- },
+ ...(author?.outsider ? [
+ { label: 'Organización', value: author.outsider.organizationName },
+ { label: 'Teléfono', value: author.outsider.phoneNumber },
+ { label: 'Ubicación', value: author.outsider.location },
] : [
- {
- label: 'Información',
- value: 'Proyecto creado por un profesor'
+ { label: 'Información', value: author?.roleName === 'professor'
+ ? 'Proyecto creado por un profesor'
+ : author?.roleName === 'student'
+ ? 'Proyecto creado por un estudiante'
+ : 'Proyecto creado por un usuario externo'
}
- ]),
+ ])
];
// Información del proyecto
const projectInfo = [
- {
- label: 'Tipo de proyecto',
- value: projectTypes.length > 0
- ? projectTypes
- .map(pt => typeof pt === 'object' ? pt.name : pt) // Soporte para ambos
- .join(', ')
- : 'No especificado'
- },
- {
- label: 'Facultades',
- value: faculties.length > 0
- ? faculties
- .map(f => typeof f === 'object' ? f.name : f)
- .join(', ')
- : 'No especificada'
- },
- {
- label: 'Tipo de problemática',
- value: problemTypes.length > 0
- ? problemTypes
- .map(pt => typeof pt === 'object' ? pt.name : pt)
- .join(', ')
- : 'No especificado'
- },
- {
- label: 'Fecha estimada',
- value: formatDate(estimatedDate)
- },
- {
- label: 'Fecha de creación',
- value: formatDate(createdAt)
- },
- {
- label: 'Estado',
- value: status === 'in_progress' ? 'En Progreso' :
- status === 'completed' ? 'Completado' :
- status === 'approved' ? 'Aprobado' :
- status === 'pending' ? 'Pendiente' : status
- },
+ { label: 'Tipo de proyecto', value: projectTypes.length > 0 ? projectTypes.map(pt => pt.name).join(', ') : 'No especificado' },
+ { label: 'Facultades', value: faculties.length > 0 ? faculties.map(f => f.name).join(', ') : 'No especificada' },
+ { label: 'Tipo de problemática', value: problemTypes.length > 0 ? problemTypes.map(pt => pt.name).join(', ') : 'No especificado' },
+ { label: 'Fecha límite', value: estimatedDate ? formatDateStringSpanish(estimatedDate.split('T')[0]) : 'No especificada' },
+ { label: 'Fecha de creación', value: createdAt ? formatDateStringSpanish(createdAt.split('T')[0]) : 'No especificada' },
+ { label: 'Estado', value: status?.name || 'Aprobado' },
];
- // Función para contactar
- const handleContact = () => {
- if (authorEmail) {
- window.location.href = `mailto:${authorEmail}?subject=Consulta sobre proyecto: ${title}`;
- } else {
- alert('No hay correo de contacto disponible para este proyecto');
- }
- };
+ // Información del equipo
+ const teamInfo = teamMembers.map(member => ({
+ label: member.role || 'Miembro',
+ value: `${member.fullName || 'Sin nombre'} (${member.email}) - ${member.universityId || 'Sin matrícula'}`
+ }));
+
+ // Manejar contacto con el autor
+ // const handleContact = () => {
+ // if (authorEmail) {
+ // window.location.href = `mailto:${authorEmail}?subject=Consulta sobre proyecto: ${title}`;
+ // } else {
+ // alert('No hay correo de contacto disponible para este proyecto');
+ // }
+ // };
return (
- {/* Botón de regreso */}
-
+ {/* Header con badge de estado */}
+
+
+
+
Detalles del proyecto
@@ -250,55 +265,23 @@ export default function ProjectDetails() {
{/* Columna izquierda: Imagen, descripción y archivos adjuntos */}
- {/* Banner */}
-
-
- {/* Resumen del proyecto */}
-
-
- {/* Archivos adjuntos */}
+
+
{attachments.length > 0 && (
Documentos adjuntos
-
{attachments.map((file, index) => (
))}
-
- )}
-
-
- {/* Columna derecha: Información */}
-
- {/* Información del autor */}
-
- Información del autor
-
-
-
- {/* Información del proyecto */}
-
- Información del proyecto
-
-
-
- {/* Botones de acción */}
-
- {/* Botón Descargar Todos los Archivos */}
+ {/* Botón Descargar Todos los Archivos */}
@@ -325,32 +308,181 @@ export default function ProjectDetails() {
{downloadError}
)}
-
+
+ )}
+
+
+ {/* Columna derecha: Información y acciones */}
+
+ {/* Información del autor */}
+
+ Información del autor
+
+
+
+ {/* Información del proyecto */}
+
+ Información del proyecto
+
+
+
+ {/* Sección de Equipo (si hay miembros) */}
+ {/* {teamMembers && teamMembers.length > 0 && (
+ <>
+
+ Equipo del proyecto
+
+
+ >
+ )} */}
+
+ {/* Botones de acción */}
+
+
+ {/* Botón Ver Equipo */}
+
+
+ {/* Botón Iniciar Proyecto */}
+ {project?.status?.slug === 'project_approved' && (
+
+ )}
+
+ {/* Botón Rollback */}
+ {canRollback && (
+
+ )}
+
+ {/* Botón Actualizar Fecha Límite */}
+ {canUpdateDeadline && (
+
+ )}
+
{/* Botón Ponerse en Contacto */}
-
+ */}
- {/* Información adicional sobre el proyecto */}
-
-
-
-
-
-
Proyecto aprobado
-
- Este proyecto fue aprobado el {formatDate(createdAt)}. Puedes gestionar su progreso desde esta página.
-
+ {/* Mensaje informativo si el proyecto está iniciado */}
+ {isProjectStarted && (
+
+
+
+
+
+
+
+ Proyecto en progreso
+
+
+ El equipo ya no puede ser modificado porque el proyecto ha sido iniciado.
+
+
+
-
+ )}
+
+ {/* Advertencia si el equipo no está completo */}
+ {canStart && !validation.canStart && (
+
+
+
+
+
+
+
+ Equipo incompleto
+
+
+ Complete el equipo según las restricciones para poder iniciar el proyecto:
+
+
+ {validation.errors.map((error, idx) => (
+ - {error}
+ ))}
+
+
+
+
+ )}
+
+ {/* Modales */}
+ setShowStartModal(false)}
+ project={project}
+ onConfirm={confirmStart}
+ isLoading={isStarting}
+ />
+ setShowRollbackModal(false)}
+ project={project}
+ onConfirm={confirmRollback}
+ isLoading={isRollingBack}
+ />
+ setShowUpdateDeadlineModal(false)}
+ project={project}
+ onConfirm={confirmUpdateDeadline}
+ isLoading={isUpdatingDeadline}
+ />
);
}
\ No newline at end of file
diff --git a/apps/web/src/features/projects/pages/RequestProject.jsx b/apps/web/src/features/projects/pages/RequestProject.jsx
index 62e3ccf9..0b30ad93 100644
--- a/apps/web/src/features/projects/pages/RequestProject.jsx
+++ b/apps/web/src/features/projects/pages/RequestProject.jsx
@@ -12,21 +12,22 @@ const RequestProject = () => {
handleChange,
handleBannerSelection,
handleRemoveAttachment,
- handleSubmit
+ handleSubmit,
+ deadlineConstraints,
} = useRequestProject();
const { isComplete, isLoading: profileLoading } = useProfileStatus();
const [showHelp, setShowHelp] = useState(false);
const [showModal, setShowModal] = useState(false);
+ // Mostrar modal si el perfil está incompleto
useEffect(() => {
- // Mostrar modal si el perfil está incompleto
if (!profileLoading && !isComplete) {
setShowModal(true);
}
}, [profileLoading, isComplete]);
- // Mostrar loading mientras se verifica el perfil
+ // Loading state mientras se verifica el perfil
if (profileLoading) {
return (
@@ -43,7 +44,7 @@ const RequestProject = () => {
setShowModal(false)}
- showCloseButton={false} // No permitir cerrar sin completar perfil
+ showCloseButton={false}
/>
@@ -66,8 +67,9 @@ const RequestProject = () => {
+ {/* Panel de ayuda con recomendaciones */}
{showHelp && (
-
+
Recomendaciones para llenar el formulario:
- Título del proyecto: Sé claro y conciso. Usa un título que describa en pocas palabras el propósito del proyecto.
@@ -83,6 +85,7 @@ const RequestProject = () => {
)}
+ {/* Formulario de solicitud */}
{
handleBannerSelection={handleBannerSelection}
handleRemoveAttachment={handleRemoveAttachment}
handleSubmit={handleSubmit}
+ deadlineConstraints={deadlineConstraints}
/>
>
);
};
-export default RequestProject;
\ No newline at end of file
+export default RequestProject;
diff --git a/apps/web/src/features/projects/projectsService.js b/apps/web/src/features/projects/projectsService.js
index b8683ab4..0d24bc97 100644
--- a/apps/web/src/features/projects/projectsService.js
+++ b/apps/web/src/features/projects/projectsService.js
@@ -8,14 +8,11 @@ export async function getCSRFToken() {
});
const { csrfToken } = await res.json();
-
return csrfToken;
}
/**
* Obtiene metadata para explorar aplicaciones (facultades disponibles)
- * @returns {Promise