Introduce two new template partials for projects: a kanban board interface and a multi-step project form. These additions provide enhanced project management capabilities and streamlined project creation. **New Features** - **Kanban board** with four columns (To Do, In Progress, Review, Done) - Dynamic task cards with drag-and-drop functionality - Task CRUD operations via modals (Add Task, Edit Task) - Status badges showing task counts per column - **Project form** with three-step wizard interface - Form validation and dynamic step navigation - Responsive design for both components **Enhancements & Refinements** - Interactive UI elements with hover states and visual feedback - Automatic count updates when tasks are moved or modified - HTMX integration for seamless form submissions - Clean and modern styling using utility classes - Accessibility improvements with proper ARIA labels and focus management
423 lines
21 KiB
HTML
423 lines
21 KiB
HTML
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
|
<!-- TODO Column -->
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="font-bold text-base-content/70 uppercase text-sm tracking-wider">To Do</h3>
|
|
<span class="badge badge-sm" id="todo-count">{{ tasks_todo|length }}</span>
|
|
</div>
|
|
|
|
<!-- Add Task Button -->
|
|
<button onclick="add_task_modal.showModal()" class="btn btn-sm btn-primary w-full mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Add Task
|
|
</button>
|
|
|
|
<div id="kanban-todo-list"
|
|
class="column-list flex flex-col gap-3 min-h-[150px] md:min-h-[200px] bg-base-100/50 p-2 rounded-lg"
|
|
data-status="TODO">
|
|
{% for task in tasks_todo %}
|
|
<div class="task-item card bg-base-100 shadow-sm hover:shadow-md p-3 cursor-move" draggable="true"
|
|
data-task-id="{{ task.id }}" id="task-{{ task.id }}">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium">{{ task.title }}</p>
|
|
{% if task.due_date %}
|
|
<div class="text-xs text-base-content/60 mt-1 flex items-center gap-1">
|
|
<span class="icon-[tabler--calendar] size-3"></span>
|
|
{{ task.due_date|date:"M d" }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-xs btn-square">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</div>
|
|
<ul tabindex="0"
|
|
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-32">
|
|
<li><a class="edit-task-btn" data-task-id="{{ task.id }}"
|
|
data-task-title="{{ task.title }}"
|
|
data-task-due-date="{{ task.due_date|date:'Y-m-d'|default:'' }}">Edit</a></li>
|
|
<li><a class="delete-task-btn" data-task-id="{{ task.id }}">Delete</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IN PROGRESS Column -->
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="font-bold text-info uppercase text-sm tracking-wider">In Progress</h3>
|
|
<span class="badge badge-info badge-sm" id="progress-count">{{ tasks_in_progress|length }}</span>
|
|
</div>
|
|
<div id="kanban-progress-list"
|
|
class="column-list flex flex-col gap-3 min-h-[150px] md:min-h-[200px] bg-base-100/50 p-2 rounded-lg"
|
|
data-status="IN_PROGRESS">
|
|
{% for task in tasks_in_progress %}
|
|
<div class="task-item card bg-base-100 shadow-sm hover:shadow-md p-3 cursor-move" draggable="true"
|
|
data-task-id="{{ task.id }}" id="task-{{ task.id }}">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium">{{ task.title }}</p>
|
|
{% if task.due_date %}
|
|
<div class="text-xs text-base-content/60 mt-1 flex items-center gap-1">
|
|
<span class="icon-[tabler--calendar] size-3"></span>
|
|
{{ task.due_date|date:"M d" }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-xs btn-square">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</div>
|
|
<ul tabindex="0"
|
|
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-32">
|
|
<li><a class="edit-task-btn" data-task-id="{{ task.id }}"
|
|
data-task-title="{{ task.title }}"
|
|
data-task-due-date="{{ task.due_date|date:'Y-m-d'|default:'' }}">Edit</a></li>
|
|
<li><a class="delete-task-btn" data-task-id="{{ task.id }}">Delete</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REVIEW Column -->
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="font-bold text-warning uppercase text-sm tracking-wider">Review</h3>
|
|
<span class="badge badge-warning badge-sm" id="review-count">{{ tasks_review|length }}</span>
|
|
</div>
|
|
<div id="kanban-review-list"
|
|
class="column-list flex flex-col gap-3 min-h-[150px] md:min-h-[200px] bg-base-100/50 p-2 rounded-lg"
|
|
data-status="REVIEW">
|
|
{% for task in tasks_review %}
|
|
<div class="task-item card bg-base-100 shadow-sm hover:shadow-md p-3 cursor-move" draggable="true"
|
|
data-task-id="{{ task.id }}" id="task-{{ task.id }}">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium">{{ task.title }}</p>
|
|
{% if task.due_date %}
|
|
<div class="text-xs text-base-content/60 mt-1 flex items-center gap-1">
|
|
<span class="icon-[tabler--calendar] size-3"></span>
|
|
{{ task.due_date|date:"M d" }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-xs btn-square">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</div>
|
|
<ul tabindex="0"
|
|
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-32">
|
|
<li><a class="edit-task-btn" data-task-id="{{ task.id }}"
|
|
data-task-title="{{ task.title }}"
|
|
data-task-due-date="{{ task.due_date|date:'Y-m-d'|default:'' }}">Edit</a></li>
|
|
<li><a class="delete-task-btn" data-task-id="{{ task.id }}">Delete</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DONE Column -->
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="font-bold text-success uppercase text-sm tracking-wider">Done</h3>
|
|
<span class="badge badge-success badge-sm" id="done-count">{{ tasks_done|length }}</span>
|
|
</div>
|
|
<div id="kanban-done-list"
|
|
class="column-list flex flex-col gap-3 min-h-[150px] md:min-h-[200px] bg-base-100/50 p-2 rounded-lg"
|
|
data-status="DONE">
|
|
{% for task in tasks_done %}
|
|
<div class="task-item card bg-base-100 shadow-sm hover:shadow-md p-3 cursor-move" draggable="true"
|
|
data-task-id="{{ task.id }}" id="task-{{ task.id }}">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium">{{ task.title }}</p>
|
|
{% if task.due_date %}
|
|
<div class="text-xs text-base-content/60 mt-1 flex items-center gap-1">
|
|
<span class="icon-[tabler--calendar] size-3"></span>
|
|
{{ task.due_date|date:"M d" }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-xs btn-square">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</div>
|
|
<ul tabindex="0"
|
|
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-32">
|
|
<li><a class="edit-task-btn" data-task-id="{{ task.id }}"
|
|
data-task-title="{{ task.title }}"
|
|
data-task-due-date="{{ task.due_date|date:'Y-m-d'|default:'' }}">Edit</a></li>
|
|
<li><a class="delete-task-btn" data-task-id="{{ task.id }}">Delete</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Task Modal -->
|
|
<dialog id="add_task_modal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg mb-4">Add New Task</h3>
|
|
<form id="add-task-form" hx-post="{% url 'task_create' project_id=project.pk %}" hx-target="#kanban-todo-list"
|
|
hx-swap="afterbegin" class="space-y-4">
|
|
{% csrf_token %}
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Task Title</span>
|
|
</label>
|
|
<input type="text" name="title" placeholder="Enter task title..." class="input input-bordered w-full"
|
|
required />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Due Date (Optional)</span>
|
|
</label>
|
|
<input type="date" name="due_date" class="input input-bordered w-full" />
|
|
</div>
|
|
<input type="hidden" name="status" value="TODO" />
|
|
<div class="modal-action">
|
|
<button type="button" class="btn"
|
|
onclick="add_task_modal.close(); document.getElementById('add-task-form').reset();">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Add Task</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Edit Task Modal -->
|
|
<dialog id="edit_task_modal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg mb-4">Edit Task</h3>
|
|
<form id="edit-task-form" hx-post="" hx-swap="outerHTML" class="space-y-4">
|
|
{% csrf_token %}
|
|
<input type="hidden" id="edit-task-id" name="task_id" />
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Task Title</span>
|
|
</label>
|
|
<input type="text" id="edit-task-title" name="title" placeholder="Enter task title..."
|
|
class="input input-bordered w-full" required />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Due Date (Optional)</span>
|
|
</label>
|
|
<input type="date" id="edit-task-due-date" name="due_date" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" onclick="edit_task_modal.close();">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<script>
|
|
// Listen for successful task creation/edit to close modal and reset form
|
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
|
// Close add modal if task was added to todo list
|
|
if (event.detail.target.id === 'kanban-todo-list') {
|
|
add_task_modal.close();
|
|
document.getElementById('add-task-form').reset();
|
|
updateCounts();
|
|
}
|
|
// Close edit modal if a task was updated
|
|
if (event.detail.target.id && event.detail.target.id.startsWith('task-')) {
|
|
edit_task_modal.close();
|
|
// Re-setup drag and drop for the updated task
|
|
setupTaskDragDrop(event.detail.target);
|
|
}
|
|
});
|
|
|
|
// Function to setup drag and drop for a task element
|
|
function setupTaskDragDrop(task) {
|
|
task.addEventListener('dragstart', (e) => {
|
|
task.classList.add('opacity-50');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', task.dataset.taskId);
|
|
});
|
|
|
|
task.addEventListener('dragend', (e) => {
|
|
task.classList.remove('opacity-50');
|
|
});
|
|
}
|
|
|
|
// Setup drag and drop for all existing task items
|
|
document.querySelectorAll('.task-item').forEach(task => {
|
|
setupTaskDragDrop(task);
|
|
});
|
|
|
|
// Setup drop zones
|
|
document.querySelectorAll('.column-list').forEach(column => {
|
|
column.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const dragging = document.querySelector('.opacity-50');
|
|
const afterElement = getDragAfterElement(column, e.clientY);
|
|
|
|
if (afterElement == null) {
|
|
column.appendChild(dragging);
|
|
} else {
|
|
column.insertBefore(dragging, afterElement);
|
|
}
|
|
});
|
|
|
|
column.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
const taskId = e.dataTransfer.getData('text/plain');
|
|
const newStatus = column.dataset.status;
|
|
|
|
// Update task status via fetch
|
|
updateTaskStatus(taskId, newStatus);
|
|
updateCounts();
|
|
});
|
|
});
|
|
|
|
// Listen for HTMX afterSettle to setup drag-drop on new tasks
|
|
document.body.addEventListener('htmx:afterSettle', function (event) {
|
|
// Setup drag-drop for newly added tasks
|
|
// Check if the target contains task items
|
|
const newTasks = event.detail.target.querySelectorAll('.task-item');
|
|
newTasks.forEach(task => {
|
|
// Only setup if not already setup (check if it has the event listener)
|
|
if (!task.hasAttribute('data-drag-setup')) {
|
|
setupTaskDragDrop(task);
|
|
task.setAttribute('data-drag-setup', 'true');
|
|
}
|
|
});
|
|
});
|
|
|
|
function getDragAfterElement(container, y) {
|
|
const draggableElements = [...container.querySelectorAll('.task-item:not(.opacity-50)')];
|
|
|
|
return draggableElements.reduce((closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset: offset, element: child };
|
|
} else {
|
|
return closest;
|
|
}
|
|
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
|
}
|
|
|
|
function updateTaskStatus(taskId, newStatus) {
|
|
// Use fetch to update task status
|
|
fetch(`/projects/tasks/${taskId}/update/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
|
},
|
|
body: `status=${newStatus}`
|
|
});
|
|
}
|
|
|
|
// Event delegation for edit and delete buttons
|
|
document.addEventListener('click', function (e) {
|
|
// Handle edit button
|
|
if (e.target.classList.contains('edit-task-btn')) {
|
|
const taskId = e.target.dataset.taskId;
|
|
const taskTitle = e.target.dataset.taskTitle;
|
|
const taskDueDate = e.target.dataset.taskDueDate;
|
|
|
|
// Populate the edit form
|
|
document.getElementById('edit-task-id').value = taskId;
|
|
document.getElementById('edit-task-title').value = taskTitle;
|
|
document.getElementById('edit-task-due-date').value = taskDueDate;
|
|
|
|
// Set the form action URL
|
|
document.getElementById('edit-task-form').setAttribute(
|
|
'hx-post',
|
|
`/projects/tasks/${taskId}/edit/`
|
|
);
|
|
document.getElementById('edit-task-form').setAttribute(
|
|
'hx-target',
|
|
`#task-${taskId}`
|
|
);
|
|
|
|
// Reinitialize HTMX for the form
|
|
htmx.process(document.getElementById('edit-task-form'));
|
|
|
|
// Open the modal
|
|
edit_task_modal.showModal();
|
|
}
|
|
|
|
// Handle delete button
|
|
if (e.target.classList.contains('delete-task-btn')) {
|
|
const taskId = e.target.dataset.taskId;
|
|
if (confirm('Are you sure you want to delete this task?')) {
|
|
const taskElement = document.getElementById(`task-${taskId}`);
|
|
if (taskElement) {
|
|
// Use fetch to delete task
|
|
fetch(`/projects/tasks/${taskId}/delete/`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
|
}
|
|
}).then(() => {
|
|
taskElement.remove();
|
|
updateCounts();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function updateCounts() {
|
|
document.getElementById('todo-count').textContent =
|
|
document.getElementById('kanban-todo-list').querySelectorAll('.task-item').length;
|
|
document.getElementById('progress-count').textContent =
|
|
document.getElementById('kanban-progress-list').querySelectorAll('.task-item').length;
|
|
document.getElementById('review-count').textContent =
|
|
document.getElementById('kanban-review-list').querySelectorAll('.task-item').length;
|
|
document.getElementById('done-count').textContent =
|
|
document.getElementById('kanban-done-list').querySelectorAll('.task-item').length;
|
|
}
|
|
</script> |