Files
GestorTareas/resources/js/Pages/Projects/Show.vue
2026-08-03 23:59:45 +02:00

583 lines
23 KiB
Vue

<script setup>
import { nextTick, ref, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import draggable from 'vuedraggable';
import { Modal } from 'bootstrap';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
const props = defineProps({ project: Object });
const columns = [
{ key: 'todo', label: 'Sin empezar' },
{ key: 'in_progress', label: 'En progreso' },
{ key: 'blocked', label: 'Bloqueado' },
{ key: 'done', label: 'Hecho' },
];
const priorities = [
{ value: 'low', label: 'Baja' },
{ value: 'medium', label: 'Media' },
{ value: 'high', label: 'Alta' },
{ value: 'urgent', label: 'Urgente' },
];
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
function priorityLabel(priority) {
return priorities.find(item => item.value === priority)?.label || 'Media';
}
function priorityOptionClass(option, selectedPriority) {
const color = {
urgent: 'danger',
high: 'warning',
medium: 'info',
low: 'secondary',
}[option];
return [
`priority-option-${color}`,
{ 'active': option === selectedPriority },
];
}
function sortTasksByPriority(tasks) {
return [...tasks].sort((firstTask, secondTask) => {
const firstPriority = priorityOrder[firstTask.priority] ?? priorityOrder.medium;
const secondPriority = priorityOrder[secondTask.priority] ?? priorityOrder.medium;
return firstPriority - secondPriority;
});
}
function groupTasksByStatus(tasks) {
return Object.fromEntries(
columns.map(col => [col.key, sortTasksByPriority(tasks.filter(task => task.status === col.key))]),
);
}
const tasksByStatus = ref(groupTasksByStatus(props.project.tasks));
watch(
() => props.project.tasks,
(tasks) => {
tasksByStatus.value = groupTasksByStatus(tasks);
},
);
const modalElement = ref(null);
let modalInstance = null;
const id = ref(null);
const title = ref('');
const description = ref('');
const priority = ref('medium');
const isEditMode = ref(false);
const openTask = ref(null);
const newSubtaskTitle = ref('');
const isEditingTaskTitle = ref(false);
const taskTitleDraft = ref('');
const isSavingTaskTitle = ref(false);
const taskTitleInput = ref(null);
const isEditingTaskDescription = ref(false);
const taskDescriptionDraft = ref('');
const isSavingTaskDescription = ref(false);
const taskDescriptionInput = ref(null);
const editingSubtaskId = ref(null);
const subtaskTitleDraft = ref('');
const isSavingSubtaskTitle = ref(false);
const taskPriorityDraft = ref('medium');
const isSavingTaskPriority = ref(false);
function statusBorderClass(status) {
return {
todo: 'border-secondary',
in_progress: 'border-primary',
blocked: 'border-danger',
done: 'border-success',
}[status] || 'border-secondary';
}
const abrirCrear = () => {
isEditMode.value = false;
id.value = null;
title.value = '';
description.value = '';
priority.value = 'medium';
mostrarModal();
};
const abrirEditar = (item) => {
isEditMode.value = true;
id.value = item.id;
title.value = item.title;
description.value = item.description;
priority.value = item.priority || 'medium';
mostrarModal();
};
const mostrarModal = () => {
if (!modalElement.value) {
return;
}
if (!modalInstance) {
modalInstance = new Modal(modalElement.value);
}
modalInstance.show();
};
const ocultarModal = () => {
modalInstance?.hide();
};
const guardarTarea = async () => {
try {
if (isEditMode.value) {
router.patch(`/tasks/${id.value}`, { title: title.value, description: description.value, priority: priority.value }, {
onSuccess: () => {
title.value = '';
description.value = '';
priority.value = 'medium';
ocultarModal();
},
});
return;
}
router.post(`/projects/${props.project.id}/tasks`, { title: title.value, description: description.value, priority: priority.value }, {
onSuccess: () => {
title.value = '';
description.value = '';
priority.value = 'medium';
ocultarModal();
},
});
} catch (error) {
console.error('Error al guardar:', error);
}
};
function onTaskMoved(event, statusKey) {
if (!event.added?.element) {
return;
}
const movedTask = event.added.element;
movedTask.status = statusKey;
tasksByStatus.value[statusKey] = sortTasksByPriority(tasksByStatus.value[statusKey]);
router.patch(`/tasks/${movedTask.id}`, { status: statusKey }, {
preserveScroll: true,
preserveState: true,
});
}
function openTaskDetails(task) {
openTask.value = task;
taskPriorityDraft.value = task.priority || 'medium';
}
function saveTaskPriority() {
if (!openTask.value || isSavingTaskPriority.value) return;
const updatedPriority = taskPriorityDraft.value;
if (updatedPriority === (openTask.value.priority || 'medium')) return;
isSavingTaskPriority.value = true;
router.patch(`/tasks/${openTask.value.id}`, { priority: updatedPriority }, {
preserveScroll: true,
onSuccess: () => {
openTask.value.priority = updatedPriority;
tasksByStatus.value[openTask.value.status] = sortTasksByPriority(tasksByStatus.value[openTask.value.status]);
},
onFinish: () => {
isSavingTaskPriority.value = false;
},
});
}
function addSubtask() {
if (!newSubtaskTitle.value.trim() || !openTask.value) return;
router.post(`/tasks/${openTask.value.id}/subtasks`, { title: newSubtaskTitle.value }, {
onSuccess: (page) => {
newSubtaskTitle.value = '';
const updatedTask = page.props.project.tasks.find(task => task.id === openTask.value.id);
if (updatedTask) {
openTask.value = updatedTask;
}
},
preserveScroll: true,
});
}
function toggleSubtask(subtask) {
router.patch(`/subtasks/${subtask.id}`, { completed: !subtask.completed }, { preserveScroll: true });
}
async function startEditingSubtask(subtask) {
if (isSavingSubtaskTitle.value) return;
editingSubtaskId.value = subtask.id;
subtaskTitleDraft.value = subtask.title;
await nextTick();
document.querySelector('.subtask-title-input')?.focus();
}
function cancelEditingSubtask() {
editingSubtaskId.value = null;
subtaskTitleDraft.value = '';
}
function saveSubtaskTitle(subtask) {
if (isSavingSubtaskTitle.value || editingSubtaskId.value !== subtask.id) return;
const updatedTitle = subtaskTitleDraft.value.trim();
if (!updatedTitle || updatedTitle === subtask.title) {
cancelEditingSubtask();
return;
}
isSavingSubtaskTitle.value = true;
router.patch(`/subtasks/${subtask.id}`, { title: updatedTitle }, {
preserveScroll: true,
onSuccess: () => {
subtask.title = updatedTitle;
cancelEditingSubtask();
},
onFinish: () => {
isSavingSubtaskTitle.value = false;
},
});
}
async function startEditingTaskTitle() {
if (!openTask.value) return;
taskTitleDraft.value = openTask.value.title;
isEditingTaskTitle.value = true;
await nextTick();
taskTitleInput.value?.focus();
}
function cancelEditingTaskTitle() {
isEditingTaskTitle.value = false;
taskTitleDraft.value = '';
}
function saveTaskTitle() {
if (!openTask.value || isSavingTaskTitle.value || !isEditingTaskTitle.value) return;
const updatedTitle = taskTitleDraft.value.trim();
if (!updatedTitle || updatedTitle === openTask.value.title) {
cancelEditingTaskTitle();
return;
}
isSavingTaskTitle.value = true;
router.patch(`/tasks/${openTask.value.id}`, { title: updatedTitle }, {
preserveScroll: true,
onSuccess: () => {
openTask.value.title = updatedTitle;
cancelEditingTaskTitle();
},
onFinish: () => {
isSavingTaskTitle.value = false;
},
});
}
function sanitizeDescription(value) {
if (!value) return '';
const documentParser = new DOMParser();
const parsedDocument = documentParser.parseFromString(value, 'text/html');
const allowedTags = ['BR', 'B', 'STRONG', 'I', 'EM', 'U', 'P', 'UL', 'OL', 'LI'];
parsedDocument.body.querySelectorAll('*').forEach((element) => {
if (!allowedTags.includes(element.tagName)) {
element.replaceWith(...element.childNodes);
return;
}
[...element.attributes].forEach(attribute => element.removeAttribute(attribute.name));
});
return parsedDocument.body.innerHTML;
}
async function startEditingTaskDescription() {
if (!openTask.value) return;
taskDescriptionDraft.value = sanitizeDescription(openTask.value.description || '');
isEditingTaskDescription.value = true;
await nextTick();
taskDescriptionInput.value.innerHTML = taskDescriptionDraft.value;
taskDescriptionInput.value?.focus();
}
function cancelEditingTaskDescription() {
isEditingTaskDescription.value = false;
taskDescriptionDraft.value = '';
}
function saveTaskDescription() {
if (!openTask.value || isSavingTaskDescription.value || !isEditingTaskDescription.value) return;
const updatedDescription = sanitizeDescription(taskDescriptionInput.value?.innerHTML || '').trim();
const currentDescription = sanitizeDescription(openTask.value.description || '').trim();
if (updatedDescription === currentDescription) {
cancelEditingTaskDescription();
return;
}
isSavingTaskDescription.value = true;
router.patch(`/tasks/${openTask.value.id}`, { description: updatedDescription || null }, {
preserveScroll: true,
onSuccess: () => {
openTask.value.description = updatedDescription;
cancelEditingTaskDescription();
},
onFinish: () => {
isSavingTaskDescription.value = false;
},
});
}
function formatDescription(command) {
taskDescriptionInput.value?.focus();
document.execCommand(command, false);
taskDescriptionDraft.value = taskDescriptionInput.value?.innerHTML || '';
}
</script>
<template>
<AuthenticatedLayout>
<template #header>
<h2 class="h4 mb-0">{{ project.name }}</h2>
</template>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" @click="abrirCrear">
Crear tarea
</button>
</div>
<div class="row g-3">
<div v-for="col in columns" :key="col.key" class="col-12 col-md-6 col-xl-3">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h3 class="h5 mb-3">{{ col.label }}</h3>
<draggable
v-model="tasksByStatus[col.key]"
:group="{ name: 'tasks', pull: true, put: true }"
item-key="id"
:animation="150"
ghost-class="bg-primary-subtle"
drag-class="shadow-lg"
class="d-flex flex-column gap-2"
style="min-height: 12rem;"
@change="e => onTaskMoved(e, col.key)"
>
<template #item="{ element: task }">
<div
class="card border-0 border-start border-4 shadow-sm cursor-pointer"
:class="statusBorderClass(task.status)"
@click="openTaskDetails(task)"
>
<div class="card-body py-3">
<p class="fw-semibold mb-1">{{ task.title }}</p>
<span class="badge mb-1" :class="`text-bg-${task.priority === 'urgent' ? 'danger' : task.priority === 'high' ? 'warning' : task.priority === 'low' ? 'secondary' : 'info'}`">
{{ priorityLabel(task.priority) }}
</span>
<div
v-if="task.description"
class="text-muted mb-1 task-description-preview"
v-html="sanitizeDescription(task.description)"
></div>
<p class="text-muted small mb-0" v-if="task.subtasks?.length">
{{ task.subtasks.filter(s => s.completed).length }}/{{ task.subtasks.length }} subtareas
</p>
</div>
</div>
</template>
</draggable>
</div>
</div>
</div>
</div>
<div v-if="openTask" class="offcanvas offcanvas-end show" tabindex="-1" aria-hidden="false" @click.self="openTask = null" style="--bs-offcanvas-width: min(100vw, 32rem);">
<div class="offcanvas-header">
<input
v-if="isEditingTaskTitle"
ref="taskTitleInput"
v-model="taskTitleDraft"
class="form-control form-control-lg offcanvas-title"
type="text"
maxlength="255"
aria-label="Título de la tarea"
:disabled="isSavingTaskTitle"
@blur="saveTaskTitle"
@keyup.enter="saveTaskTitle"
@keyup.esc="cancelEditingTaskTitle"
/>
<h3
v-else
class="offcanvas-title"
role="button"
tabindex="0"
title="Pulsar para editar"
@click="startEditingTaskTitle"
@keyup.enter="startEditingTaskTitle"
>
{{ openTask.title }}
</h3>
<button type="button" class="btn-close" @click="openTask = null"></button>
</div>
<div class="offcanvas-body">
<div class="mb-3">
<label for="task-priority" class="form-label">Prioridad</label>
<div class="priority-picker" role="radiogroup" aria-label="Prioridad de la tarea">
<button
v-for="item in priorities"
:key="item.value"
type="button"
class="priority-option"
:class="priorityOptionClass(item.value, taskPriorityDraft)"
:aria-pressed="taskPriorityDraft === item.value"
:disabled="isSavingTaskPriority"
@click="taskPriorityDraft = item.value; saveTaskPriority()"
>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-if="isEditingTaskDescription" class="mb-3">
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Formato de descripción">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary fw-bold" title="Negrita" @mousedown.prevent @click="formatDescription('bold')">B</button>
<button type="button" class="btn btn-outline-secondary fst-italic" title="Cursiva" @mousedown.prevent @click="formatDescription('italic')">I</button>
<button type="button" class="btn btn-outline-secondary text-decoration-underline" title="Subrayado" @mousedown.prevent @click="formatDescription('underline')">U</button>
</div>
<div class="btn-group btn-group-sm ms-2" role="group">
<button type="button" class="btn btn-outline-secondary" title="Lista con viñetas" @mousedown.prevent @click="formatDescription('insertUnorderedList')">&#8226; Lista</button>
<button type="button" class="btn btn-outline-secondary" title="Lista numerada" @mousedown.prevent @click="formatDescription('insertOrderedList')">1. Lista</button>
</div>
</div>
<div
ref="taskDescriptionInput"
class="form-control task-description-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label="Descripción de la tarea"
data-placeholder="Escribe la descripción de la tarea..."
:class="{ 'pe-none': isSavingTaskDescription }"
@input="taskDescriptionDraft = $event.currentTarget.innerHTML"
@blur="saveTaskDescription"
@keydown.ctrl.enter.prevent="saveTaskDescription"
@keydown.esc="cancelEditingTaskDescription"
></div>
</div>
<p
v-else
class="text-muted"
style="white-space: pre-wrap;"
role="button"
tabindex="0"
title="Pulsar para editar"
@click="startEditingTaskDescription"
@keyup.enter="startEditingTaskDescription"
>
<span v-if="openTask.description" v-html="sanitizeDescription(openTask.description)"></span>
<span v-else>Añadir descripción...</span>
</p>
<form @submit.prevent="addSubtask" class="row g-2 mb-3">
<div class="col">
<input v-model="newSubtaskTitle" placeholder="Nueva subtarea..." class="form-control" />
</div>
<div class="col-auto">
<button class="btn btn-primary">+</button>
</div>
</form>
<ul class="list-group">
<li v-for="sub in openTask.subtasks" :key="sub.id" class="list-group-item d-flex align-items-center gap-2">
<input type="checkbox" :checked="sub.completed" @change="toggleSubtask(sub)" />
<input
v-if="editingSubtaskId === sub.id"
v-model="subtaskTitleDraft"
class="form-control form-control-sm subtask-title-input"
type="text"
maxlength="255"
aria-label="Título de la subtarea"
:disabled="isSavingSubtaskTitle"
@blur="saveSubtaskTitle(sub)"
@keyup.enter="saveSubtaskTitle(sub)"
@keyup.esc="cancelEditingSubtask"
/>
<span
v-else
:class="{ 'text-decoration-line-through text-muted': sub.completed }"
class="flex-grow-1"
role="button"
tabindex="0"
title="Pulsar para editar"
@click="startEditingSubtask(sub)"
@keyup.enter="startEditingSubtask(sub)"
>
{{ sub.title }}
</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Modal para crear y editar tareas -->
<div class="modal fade" id="TaskModal" tabindex="-1" aria-hidden="true" ref="modalElement">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">
{{ isEditMode ? 'Editar tarea' : 'Nueva tarea' }}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form @submit.prevent="guardarTarea">
<div class="modal-body">
<div class="mb-3">
<label for="title" class="form-label">Nombre</label>
<input type="text" class="form-control" id="title" v-model="title" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Descripción</label>
<input type="text" class="form-control" id="description" v-model="description">
</div>
<div class="mb-3">
<label for="priority" class="form-label">Prioridad</label>
<div class="priority-picker" role="radiogroup" aria-label="Prioridad de la tarea">
<button
v-for="item in priorities"
:key="item.value"
type="button"
class="priority-option"
:class="priorityOptionClass(item.value, priority)"
:aria-pressed="priority === item.value"
@click="priority = item.value"
>
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">{{ isEditMode ? 'Actualizar tarea' : 'Crear tarea' }}</button>
</div>
</form>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>