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.
+
+
+
+
+
+
+ | Nombre |
+ Estado |
+ Prioridad |
+ SP |
+
+
+
+ {previewTasks.map((t, i) => (
+
+ | {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);