diff --git a/MtdrSpring/backend/src/main/frontend/src/components/tasks/ImportTasksModal.jsx b/MtdrSpring/backend/src/main/frontend/src/components/tasks/ImportTasksModal.jsx new file mode 100644 index 000000000..cc8db2642 --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/components/tasks/ImportTasksModal.jsx @@ -0,0 +1,210 @@ +import React, { useState, useRef } from 'react'; +import Modal from '../common/Modal'; +import Button from '../common/Button'; +import Badge from '../common/Badge'; +import LoadingSpinner from '../common/LoadingSpinner'; +import { parseAndValidateImportFile } from '../../services/importExportService'; +import { taskService } from '../../services/taskService'; +import { useToast } from '../../context/ToastContext'; + +export default function ImportTasksModal({ open, onClose, projectId, onImported }) { + const toast = useToast(); + const fileInputRef = useRef(null); + + const [step, setStep] = useState('pick'); // pick | preview | importing | done + const [validationErrors, setValidationErrors] = useState([]); + const [previewTasks, setPreviewTasks] = useState([]); + const [results, setResults] = useState(null); // { succeeded, failed: [{taskName, reason}] } + + const reset = () => { + setStep('pick'); + setValidationErrors([]); + setPreviewTasks([]); + setResults(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleFileChange = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + setValidationErrors([]); + setPreviewTasks([]); + + const { tasks, errors } = await parseAndValidateImportFile(file); + + if (errors.length > 0) { + setValidationErrors(errors); + setStep('pick'); + return; + } + + setPreviewTasks(tasks); + setStep('preview'); + }; + + const handleImport = async () => { + setStep('importing'); + const succeeded = []; + const failed = []; + + for (const task of previewTasks) { + try { + await taskService.create(projectId, { ...task, sprintId: null }); + succeeded.push(task.taskName); + } catch (err) { + failed.push({ taskName: task.taskName, reason: err.message || 'Error desconocido' }); + } + } + + setResults({ succeeded: succeeded.length, failed }); + setStep('done'); + + if (succeeded.length > 0) { + onImported(); + } + }; + + const total = previewTasks.length; + + return ( + + {step === 'pick' && ( +
+

+ Selecciona un archivo .json con las tareas a importar. + Se agregarán al backlog del proyecto. +

+ +
+ + + +

Formato esperado: array JSON con campos task_name y story_points

+ + +
+ + {validationErrors.length > 0 && ( +
+

+ El archivo contiene {validationErrors.length} error{validationErrors.length !== 1 ? 'es' : ''}: +

+
    + {validationErrors.map((err, i) => ( +
  • + + {err} +
  • + ))} +
+
+ )} + +
+

Formato del archivo:

+
{`[
+  {
+    "task_name": "Nombre de la tarea",   // requerido
+    "story_points": 3,                   // requerido, entero > 0
+    "description": "Descripción...",     // opcional
+    "status": "TODO",                    // opcional (default: TODO)
+    "priority": "HIGH"                   // opcional
+  }
+]`}
+
+
+ )} + + {step === 'preview' && ( +
+

+ Se importarán {total} tarea{total !== 1 ? 's' : ''} al backlog del proyecto. + Revisa la lista antes de continuar. +

+ +
+ + + + + + + + + + + {previewTasks.map((t, i) => ( + + + + + + + ))} + +
NombreEstadoPrioridadSP
{t.taskName}{t.priority ? : }{t.storyPoints}
+
+ +
+ + +
+
+ )} + + {step === 'importing' && ( +
+ +

Importando tareas...

+
+ )} + + {step === 'done' && results && ( +
+
0 ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-200'}`}> +

+ {results.succeeded} de {total} tarea{total !== 1 ? 's' : ''} importada{results.succeeded !== 1 ? 's' : ''} exitosamente. +

+
+ + {results.failed.length > 0 && ( +
+

+ {results.failed.length} tarea{results.failed.length !== 1 ? 's' : ''} no se pudo{results.failed.length !== 1 ? 'eron' : ''} importar: +

+
    + {results.failed.map((f, i) => ( +
  • + {f.taskName}: {f.reason} +
  • + ))} +
+
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/MtdrSpring/backend/src/main/frontend/src/pages/BacklogPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/BacklogPage.jsx index 03a4cb270..4aeac1ff9 100644 --- a/MtdrSpring/backend/src/main/frontend/src/pages/BacklogPage.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/pages/BacklogPage.jsx @@ -5,7 +5,9 @@ import { taskService } from '../services/taskService'; import { sprintService } from '../services/sprintService'; import TaskTable from '../components/tasks/TaskTable'; import Modal from '../components/common/Modal'; +import ConfirmDialog from '../components/common/ConfirmDialog'; import Badge from '../components/common/Badge'; +import Button from '../components/common/Button'; import LoadingSpinner from '../components/common/LoadingSpinner'; export default function BacklogPage() { @@ -17,13 +19,15 @@ export default function BacklogPage() { const [selectedTask, setSelectedTask] = useState(null); const [moving, setMoving] = useState(false); const [error, setError] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(null); + const [deleting, setDeleting] = useState(false); const isManager = userRole === 'MANAGER'; const load = () => { setLoading(true); Promise.all([ - taskService.list(project.id, {}), + taskService.list(project.id, { size: 500 }), sprintService.list(project.id), ]) .then(([taskData, sprintData]) => { @@ -36,6 +40,20 @@ export default function BacklogPage() { useEffect(load, [project.id]); + const doDelete = async () => { + setDeleting(true); + try { + await taskService.delete(project.id, confirmDelete.id); + setConfirmDelete(null); + setSelectedTask(null); + load(); + } catch (err) { + toast.error(err.message); + } finally { + setDeleting(false); + } + }; + const handleMoveToSprint = async (sprintId) => { setMoving(true); try { @@ -99,9 +117,27 @@ export default function BacklogPage() { )} + + {isManager && ( +
+ +
+ )} )} + setConfirmDelete(null)} + onConfirm={doDelete} + title="Eliminar tarea" + message={`"${confirmDelete?.taskName}" será eliminada permanentemente. Esta acción no se puede deshacer.`} + confirmLabel="Eliminar" + variant="danger" + loading={deleting} + /> ); } diff --git a/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx index 50c68f171..ab5a0a9cb 100644 --- a/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/pages/SprintsPage.jsx @@ -3,6 +3,7 @@ import { useProject } from '../context/ProjectContext'; import { useToast } from '../context/ToastContext'; import { sprintService } from '../services/sprintService'; import { taskService } from '../services/taskService'; +import { exportTasksToJson } from '../services/importExportService'; import SprintList from '../components/sprints/SprintList'; import SprintForm from '../components/sprints/SprintForm'; import TaskTable from '../components/tasks/TaskTable'; @@ -36,6 +37,10 @@ export default function SprintsPage() { const [confirmDeleteTask, setConfirmDeleteTask] = useState(null); const [actionLoading, setActionLoading] = useState(false); + const [editSprint, setEditSprint] = useState(null); + const [sprintSaving, setSprintSaving] = useState(false); + const [confirmDeleteSprint, setConfirmDeleteSprint] = useState(null); + const isManager = userRole === 'MANAGER'; const load = () => { @@ -110,6 +115,29 @@ export default function SprintsPage() { finally { setActionLoading(false); } }; + const handleSprintUpdate = async (form) => { + setSprintSaving(true); + try { + const updated = await sprintService.update(project.id, editSprint.id, form); + setEditSprint(null); + setSelectedSprint(updated); + load(); + } catch (err) { toast.error(err.message); } + finally { setSprintSaving(false); } + }; + + const doDeleteSprint = async () => { + setActionLoading(true); + try { + await sprintService.delete(project.id, confirmDeleteSprint.id); + setConfirmDeleteSprint(null); + setSelectedSprint(null); + setSprintTasks([]); + load(); + } catch (err) { toast.error(err.message); } + finally { setActionLoading(false); } + }; + const handleStatusChange = async (task, status) => { try { const updated = await taskService.changeStatus(project.id, task.id, status); @@ -195,16 +223,43 @@ export default function SprintsPage() { > {selectedSprint && (
-
- - - {selectedSprint.startDate} → {selectedSprint.endDate} - - {selectedSprint.goal && ( - {selectedSprint.goal} +
+
+ + + {selectedSprint.startDate} → {selectedSprint.endDate} + + {selectedSprint.goal && ( + {selectedSprint.goal} + )} +
+ {isManager && ( +
+ {selectedSprint.status !== 'CLOSED' && ( + + )} + {selectedSprint.status !== 'ACTIVE' && ( + + )} +
)}
+ {!tasksLoading && sprintTasks.length > 0 && ( +
+ +
+ )} + {tasksLoading ? ( ) : sprintTasks.length === 0 ? ( @@ -285,6 +340,29 @@ export default function SprintsPage() { variant="danger" loading={actionLoading} /> + + {/* Edit sprint modal */} + setEditSprint(null)} title="Edit sprint"> + {editSprint && ( + setEditSprint(null)} + loading={sprintSaving} + /> + )} + + + setConfirmDeleteSprint(null)} + onConfirm={doDeleteSprint} + title="Eliminar sprint" + message={`¿Eliminar "${confirmDeleteSprint?.sprintName}"? Las tareas del sprint se moverán al backlog. Esta acción no se puede deshacer.`} + confirmLabel="Eliminar" + variant="danger" + loading={actionLoading} + />
); } diff --git a/MtdrSpring/backend/src/main/frontend/src/pages/TasksPage.jsx b/MtdrSpring/backend/src/main/frontend/src/pages/TasksPage.jsx index 983755556..7dd4034ad 100644 --- a/MtdrSpring/backend/src/main/frontend/src/pages/TasksPage.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/pages/TasksPage.jsx @@ -7,6 +7,7 @@ import KanbanBoard from '../components/tasks/KanbanBoard'; import TaskTable from '../components/tasks/TaskTable'; import TaskDetail from '../components/tasks/TaskDetail'; import TaskForm from '../components/tasks/TaskForm'; +import ImportTasksModal from '../components/tasks/ImportTasksModal'; import Modal from '../components/common/Modal'; import ConfirmDialog from '../components/common/ConfirmDialog'; import Button from '../components/common/Button'; @@ -30,6 +31,7 @@ export default function TasksPage() { const [actualHoursInput, setActualHoursInput] = useState(''); const [confirmDelete, setConfirmDelete] = useState(null); const [deleting, setDeleting] = useState(false); + const [showImport, setShowImport] = useState(false); const isManager = userRole === 'MANAGER'; @@ -151,7 +153,10 @@ export default function TasksPage() { className={`text-sm px-3 py-1.5 rounded-lg transition-colors ${view === 'list' ? 'bg-gray-900 text-white' : 'text-gray-500 hover:bg-gray-100'}`} >List {isManager ? ( - + <> + + + ) : ( Only managers can create tasks )} @@ -256,6 +261,13 @@ export default function TasksPage() { variant="danger" loading={deleting} /> + + setShowImport(false)} + projectId={project.id} + onImported={() => { load(); toast.success('Tareas importadas al backlog.'); }} + />
); } diff --git a/MtdrSpring/backend/src/main/frontend/src/services/importExportService.js b/MtdrSpring/backend/src/main/frontend/src/services/importExportService.js new file mode 100644 index 000000000..fd76e9da4 --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/services/importExportService.js @@ -0,0 +1,87 @@ +const VALID_STATUSES = ['TODO', 'IN_PROGRESS', 'BLOCKED', 'DONE']; +const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH']; + +export function exportTasksToJson(tasks, sprintName) { + const data = tasks.map((t) => ({ + task_name: t.taskName, + description: t.description ?? null, + status: t.status, + priority: t.priority ?? null, + story_points: t.storyPoints, + })); + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${sprintName.replace(/\s+/g, '-')}-tasks.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export async function parseAndValidateImportFile(file) { + const text = await file.text(); + let parsed; + + try { + parsed = JSON.parse(text); + } catch { + return { tasks: [], errors: ['El archivo no contiene JSON válido.'] }; + } + + if (!Array.isArray(parsed)) { + return { tasks: [], errors: ['El archivo debe contener un array de tareas: [{...}, {...}].'] }; + } + + if (parsed.length === 0) { + return { tasks: [], errors: ['El archivo no contiene tareas (el array está vacío).'] }; + } + + const errors = []; + + parsed.forEach((item, i) => { + const n = i + 1; + + if (!item.task_name || typeof item.task_name !== 'string' || item.task_name.trim() === '') { + errors.push(`Tarea #${n}: falta el campo "task_name" o está vacío.`); + } else if (item.task_name.trim().length > 200) { + errors.push(`Tarea #${n}: "task_name" no puede superar 200 caracteres.`); + } + + if (item.story_points === undefined || item.story_points === null) { + errors.push(`Tarea #${n}: falta el campo "story_points".`); + } else if (!Number.isInteger(item.story_points) || item.story_points <= 0) { + errors.push(`Tarea #${n}: "story_points" debe ser un entero mayor a 0 (valor recibido: ${JSON.stringify(item.story_points)}).`); + } + + if (item.status !== undefined && !VALID_STATUSES.includes(item.status)) { + errors.push(`Tarea #${n}: "status" inválido "${item.status}" (valores permitidos: ${VALID_STATUSES.join(', ')}).`); + } + + if (item.priority !== undefined && item.priority !== null && !VALID_PRIORITIES.includes(item.priority)) { + errors.push(`Tarea #${n}: "priority" inválido "${item.priority}" (valores permitidos: ${VALID_PRIORITIES.join(', ')}).`); + } + + if (item.description !== undefined && item.description !== null) { + if (typeof item.description !== 'string') { + errors.push(`Tarea #${n}: "description" debe ser texto.`); + } else if (item.description.length > 1000) { + errors.push(`Tarea #${n}: "description" no puede superar 1000 caracteres.`); + } + } + }); + + if (errors.length > 0) return { tasks: [], errors }; + + const tasks = parsed.map((item) => ({ + taskName: item.task_name.trim(), + description: item.description || null, + status: item.status || 'TODO', + priority: item.priority || null, + storyPoints: item.story_points, + })); + + return { tasks, errors: [] }; +} diff --git a/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js b/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js index 3ad89a88f..e6ff26645 100644 --- a/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js +++ b/MtdrSpring/backend/src/main/frontend/src/services/sprintService.js @@ -22,4 +22,7 @@ export const sprintService = { reopen: (projectId, sprintId) => apiFetch(`/projects/${projectId}/sprints/${sprintId}/reopen`, { method: 'POST' }), + + delete: (projectId, sprintId) => + apiFetch(`/projects/${projectId}/sprints/${sprintId}`, { method: 'DELETE' }), }; diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java index 8441aac1d..39f3ea2ab 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintController.java @@ -79,6 +79,15 @@ public ResponseEntity reopen(@PathVariable Long projectId, return ResponseEntity.ok(sprintService.reopenSprint(uid(auth), projectId, sprintId)); } + @DeleteMapping("/{sprintId}") + @Operation(summary = "Delete a sprint — moves its tasks to backlog; ACTIVE sprints cannot be deleted") + public ResponseEntity delete(@PathVariable Long projectId, + @PathVariable Long sprintId, + Authentication auth) { + sprintService.deleteSprint(uid(auth), projectId, sprintId); + return ResponseEntity.noContent().build(); + } + private Long uid(Authentication auth) { return Long.parseLong(auth.getName()); } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java index 5693ee372..fea91aed3 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/sprint/SprintService.java @@ -10,6 +10,7 @@ import com.springboot.MyTodoList.common.exception.ResourceNotFoundException; import com.springboot.MyTodoList.project.Project; import com.springboot.MyTodoList.project.ProjectService; +import com.springboot.MyTodoList.task.TaskRepository; import com.springboot.MyTodoList.user.User; import com.springboot.MyTodoList.user.UserService; import lombok.RequiredArgsConstructor; @@ -22,6 +23,7 @@ public class SprintService { private final SprintRepository sprintRepository; + private final TaskRepository taskRepository; private final ProjectService projectService; private final UserService userService; private final AuditLogService auditLogService; @@ -132,6 +134,20 @@ public SprintResponse reopenSprint(Long userId, Long projectId, Long sprintId) { return sprintMapper.toResponse(sprint); } + @Transactional + public void deleteSprint(Long userId, Long projectId, Long sprintId) { + User actor = userService.findActiveUserById(userId); + Project project = projectService.findProject(projectId); + projectService.requireManager(userId, project); + Sprint sprint = findSprint(sprintId, project); + if (sprint.getStatus() == SprintStatus.ACTIVE) { + throw new ForbiddenException("Cannot delete an active sprint"); + } + taskRepository.detachTasksFromSprint(sprint); + auditLogService.log(actor, EntityType.SPRINT, sprint.getId(), AuditAction.DELETE, sprint, null); + sprintRepository.delete(sprint); + } + public Sprint findSprint(Long sprintId, Project project) { Sprint sprint = sprintRepository.findById(sprintId) .orElseThrow(() -> new ResourceNotFoundException("Sprint not found: " + sprintId)); diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java index eba2da636..517ee741a 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/task/TaskRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -78,6 +79,10 @@ SELECT t.assignedTo.id, t.assignedTo.fullName, SUM(t.storyPoints), SUM(t.actualH void deleteByProject(Project project); + @Modifying(clearAutomatically = true) + @Query("UPDATE Task t SET t.sprint = null WHERE t.sprint = :sprint") + void detachTasksFromSprint(@Param("sprint") Sprint sprint); + @Query("SELECT COUNT(t) FROM Task t WHERE t.project = :project AND t.sprint IS NULL") long countBacklogByProject(@Param("project") Project project);