Primer commit
This commit is contained in:
221
resources/js/Pages/Projects/Index.vue
Normal file
221
resources/js/Pages/Projects/Index.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { Modal } from 'bootstrap';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||
|
||||
defineProps({ projects: Array });
|
||||
|
||||
// Referencia al elemento del DOM para manejar el modal de Bootstrap
|
||||
const modalElement = ref(null);
|
||||
let modalInstance = null;
|
||||
const deleteModalElement = ref(null);
|
||||
let deleteModalInstance = null;
|
||||
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const isEditMode = ref(false);
|
||||
const descripcion = ref('');
|
||||
const id = ref(null);
|
||||
const projectToDelete = ref(null);
|
||||
|
||||
const abrirCrear = () => {
|
||||
isEditMode.value = false;
|
||||
id.value = null;
|
||||
name.value = ''; // Limpiar formulario
|
||||
descripcion.value = ''; // Limpiar formulario
|
||||
mostrarModal();
|
||||
};
|
||||
|
||||
const abrirEditar = (item) => {
|
||||
isEditMode.value = true;
|
||||
id.value = item.id;
|
||||
name.value = item.name;
|
||||
descripcion.value = item.description;
|
||||
mostrarModal();
|
||||
};
|
||||
|
||||
const eliminarProyecto = (item) => {
|
||||
projectToDelete.value = item;
|
||||
|
||||
if (!deleteModalElement.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deleteModalInstance) {
|
||||
deleteModalInstance = new Modal(deleteModalElement.value);
|
||||
}
|
||||
|
||||
deleteModalInstance.show();
|
||||
};
|
||||
|
||||
const confirmarEliminacion = () => {
|
||||
if (!projectToDelete.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = projectToDelete.value.id;
|
||||
|
||||
deleteModalInstance?.hide();
|
||||
router.delete(`/projects/${projectId}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
projectToDelete.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const mostrarModal = () => {
|
||||
if (!modalElement.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modalInstance) {
|
||||
modalInstance = new Modal(modalElement.value);
|
||||
}
|
||||
|
||||
modalInstance.show();
|
||||
};
|
||||
|
||||
const ocultarModal = () => {
|
||||
modalInstance?.hide();
|
||||
};
|
||||
|
||||
|
||||
const guardarRegistro = async () => {
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
router.put(`/projects/${id.value}`, { name: name.value, description: descripcion.value }, {
|
||||
onSuccess: () => {
|
||||
name.value = '';
|
||||
descripcion.value = '';
|
||||
ocultarModal();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.post('/projects', { name: name.value, description: descripcion.value }, {
|
||||
onSuccess: () => {
|
||||
name.value = '';
|
||||
descripcion.value = '';
|
||||
ocultarModal();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error al guardar:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<h2 class="h4 mb-0">Mis proyectos</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 proyecto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div v-for="project in projects" :key="project.id" class="col-sm-6 col-lg-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
|
||||
<Link :href="`/projects/${project.id}`" class="text-decoration-none text-dark flex-grow-1">
|
||||
<h5 class="card-title mb-0">{{ project.name }}</h5>
|
||||
</Link>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-dark text-decoration-none p-0"
|
||||
@click.stop="abrirEditar(project)"
|
||||
aria-label="Editar proyecto"
|
||||
>
|
||||
<i class="bi bi-pencil-square fs-5"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-danger text-decoration-none p-0"
|
||||
@click.stop="eliminarProyecto(project)"
|
||||
aria-label="Eliminar proyecto"
|
||||
>
|
||||
<i class="bi bi-trash fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-text text-muted mb-0">{{ project.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para crear y editar proyectos -->
|
||||
<div class="modal fade" id="proyectoModal" 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 PROYECTO' : 'NUEVO PROYECTO' }}
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form @submit.prevent="guardarRegistro">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Nombre</label>
|
||||
<input type="text" class="form-control" id="name" v-model="name" 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>
|
||||
<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 proyecto' : 'Crear proyecto' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para eliminar proyecto -->
|
||||
<div
|
||||
class="modal fade"
|
||||
id="confirmDeleteModal"
|
||||
tabindex="-1"
|
||||
aria-labelledby="confirmDeleteModalLabel"
|
||||
aria-hidden="true"
|
||||
ref="deleteModalElement"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 id="confirmDeleteModalLabel" class="modal-title fs-5">Eliminar proyecto</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
¿Seguro que quieres eliminar el proyecto
|
||||
<strong>{{ projectToDelete?.name }}</strong>?
|
||||
Esta accion eliminara todas las tareas asociadas al proyecto y no se podra deshacer.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-danger" @click="confirmarEliminacion">
|
||||
<i class="bi bi-trash me-1"></i>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
583
resources/js/Pages/Projects/Show.vue
Normal file
583
resources/js/Pages/Projects/Show.vue
Normal file
@@ -0,0 +1,583 @@
|
||||
<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-md-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 w-50 show" tabindex="-1" aria-hidden="false" @click.self="openTask = null">
|
||||
<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>
|
||||
Reference in New Issue
Block a user