583 lines
23 KiB
Vue
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')">• 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> |