1377 lines
43 KiB
HTML
1377 lines
43 KiB
HTML
{% extends "layout.html" %}
|
||
|
||
{% block content %}
|
||
<div class="page-container kanban-container">
|
||
<div class="kanban-header">
|
||
<div class="project-info">
|
||
<h2>Unified Kanban Board</h2>
|
||
<p class="project-description">Organize tasks from any project on shared boards</p>
|
||
{% if project %}
|
||
<div class="context-info project-context">
|
||
<span class="context-label">Project Context:</span>
|
||
<span class="project-code">{{ project.code }}</span>
|
||
<span class="project-name">{{ project.name }}</span>
|
||
{% if project.category %}
|
||
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
||
{{ project.category.icon or '📁' }} {{ project.category.name }}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="page-actions kanban-actions">
|
||
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
|
||
<button id="create-board-btn" class="btn btn-md btn-success">Create Board</button>
|
||
<button id="manage-columns-btn" class="btn btn-md btn-warning" style="display: none;">Manage Columns</button>
|
||
{% endif %}
|
||
{% if project %}
|
||
<a href="{{ url_for('admin_projects') }}" class="btn btn-md btn-secondary">Back to Projects</a>
|
||
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-md btn-info">Task View</a>
|
||
{% else %}
|
||
<a href="{{ url_for('kanban_overview') }}" class="btn btn-md btn-secondary">Back to Kanban Overview</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Board Selection -->
|
||
{% if boards %}
|
||
<div class="board-selection">
|
||
<label for="board-select">Select Board:</label>
|
||
<select id="board-select" class="form-control">
|
||
<option value="">Choose a board...</option>
|
||
{% for board in boards %}
|
||
<option value="{{ board.id }}" {% if selected_board_id and board.id == selected_board_id %}selected{% elif not selected_board_id and board.is_default %}selected{% endif %}>
|
||
{{ board.name }} {% if board.is_default %}(Default){% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Kanban Board -->
|
||
<div id="kanban-board" class="kanban-board" style="display: none;">
|
||
<div id="kanban-columns" class="kanban-columns">
|
||
<!-- Columns will be loaded dynamically -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- No Boards Message -->
|
||
{% if not boards %}
|
||
<div class="no-boards">
|
||
<div class="no-boards-content">
|
||
<h3>No Kanban Boards Created Yet</h3>
|
||
<p>Create your first Kanban board to start organizing tasks visually.</p>
|
||
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
|
||
<button id="create-first-board-btn" class="btn btn-md btn-primary">Create Your First Board</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Board Creation Modal -->
|
||
<div id="board-modal" class="modal" style="display: none;">
|
||
<div class="modal-content">
|
||
<span class="close">×</span>
|
||
<h3>Create Kanban Board</h3>
|
||
<form id="board-form">
|
||
{% if project %}
|
||
<input type="hidden" name="project_context" value="{{ project.id }}">
|
||
{% endif %}
|
||
|
||
<div class="form-group">
|
||
<label for="board-name">Board Name *</label>
|
||
<input type="text" id="board-name" name="name" required maxlength="100">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="board-description">Description</label>
|
||
<textarea id="board-description" name="description" rows="3"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="board-is-default" name="is_default">
|
||
Set as default board for your company
|
||
</label>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button type="submit" class="btn btn-md btn-primary">Create Board</button>
|
||
<button type="button" class="btn btn-md btn-secondary" id="cancel-board">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Card Creation/Edit Modal -->
|
||
<div id="card-modal" class="modal" style="display: none;">
|
||
<div class="modal-content">
|
||
<span class="close">×</span>
|
||
<h3 id="card-modal-title">Create Card</h3>
|
||
<form id="card-form">
|
||
<input type="hidden" id="card-id" name="card_id">
|
||
<input type="hidden" id="card-column-id" name="column_id">
|
||
|
||
<div class="form-group">
|
||
<label for="card-title">Card Title *</label>
|
||
<input type="text" id="card-title" name="title" required maxlength="200">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="card-description">Description</label>
|
||
<textarea id="card-description" name="description" rows="3"></textarea>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="card-assigned-to">Assigned To</label>
|
||
<select id="card-assigned-to" name="assigned_to_id">
|
||
<option value="">Unassigned</option>
|
||
{% for user in team_members %}
|
||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="card-project">Project Context</label>
|
||
<select id="card-project" name="project_id">
|
||
<option value="">No project context</option>
|
||
{% for project_option in available_projects %}
|
||
<option value="{{ project_option.id }}" {% if project and project_option.id == project.id %}selected{% endif %}>
|
||
[{{ project_option.code }}] {{ project_option.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="card-task">Link to Task</label>
|
||
<select id="card-task" name="task_id">
|
||
<option value="">No task linked</option>
|
||
{% for task in tasks %}
|
||
<option value="{{ task.id }}" data-project-id="{{ task.project_id }}">
|
||
[{{ task.project.code }}] {{ task.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="card-due-date">Due Date</label>
|
||
<input type="date" id="card-due-date" name="due_date">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="card-color">Card Color</label>
|
||
<input type="color" id="card-color" name="color" value="#ffffff">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button type="submit" class="btn btn-md btn-primary">Save Card</button>
|
||
<button type="button" class="btn btn-md btn-secondary" id="cancel-card">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Column Management Modal -->
|
||
<div id="column-modal" class="modal" style="display: none;">
|
||
<div class="modal-content">
|
||
<span class="close">×</span>
|
||
<h3 id="column-modal-title">Manage Columns</h3>
|
||
|
||
<div class="column-management">
|
||
<!-- Add New Column Form -->
|
||
<div class="add-column-section">
|
||
<h4>Add New Column</h4>
|
||
<form id="column-form">
|
||
<input type="hidden" id="column-board-id" name="board_id">
|
||
<input type="hidden" id="column-id" name="column_id">
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="column-name">Column Name *</label>
|
||
<input type="text" id="column-name" name="name" required maxlength="100">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="column-color">Color</label>
|
||
<input type="color" id="column-color" name="color" value="#6c757d">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="column-description">Description</label>
|
||
<textarea id="column-description" name="description" rows="2"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="column-wip-limit">WIP Limit</label>
|
||
<input type="number" id="column-wip-limit" name="wip_limit" min="1" placeholder="Optional">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-md btn-primary" id="save-column-btn">Add Column</button>
|
||
<button type="button" class="btn btn-md btn-secondary" id="cancel-column-edit">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Existing Columns List -->
|
||
<div class="existing-columns-section">
|
||
<h4>Existing Columns</h4>
|
||
<div id="columns-list" class="columns-list">
|
||
<!-- Columns will be loaded dynamically -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.kanban-container {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.kanban-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 2rem;
|
||
padding-bottom: 1rem;
|
||
border-bottom: 2px solid #dee2e6;
|
||
}
|
||
|
||
.project-info h2 {
|
||
margin: 0 0 0.5rem 0;
|
||
}
|
||
|
||
.project-description {
|
||
color: #666;
|
||
margin: 0 0 0.5rem 0;
|
||
}
|
||
|
||
/* Project context styles now inherited from common styles.css */
|
||
|
||
/* Kanban actions styles now inherited from common .page-actions */
|
||
|
||
/* Button styles now centralized in main style.css */
|
||
|
||
.board-selection {
|
||
margin-bottom: 2rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.board-selection label {
|
||
font-weight: 500;
|
||
margin: 0;
|
||
}
|
||
|
||
.board-selection select {
|
||
min-width: 250px;
|
||
}
|
||
|
||
.kanban-board {
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
min-height: 70vh;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.kanban-columns {
|
||
display: flex;
|
||
gap: 1rem;
|
||
min-width: fit-content;
|
||
}
|
||
|
||
.kanban-column {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
min-width: 300px;
|
||
max-width: 300px;
|
||
border: 1px solid #dee2e6;
|
||
height: fit-content;
|
||
}
|
||
|
||
.column-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 2px solid;
|
||
}
|
||
|
||
.column-title {
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.column-count {
|
||
background: #e9ecef;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 12px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.wip-limit-warning {
|
||
background: #fff3cd !important;
|
||
color: #856404 !important;
|
||
}
|
||
|
||
.column-cards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.kanban-card {
|
||
background: white;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 6px;
|
||
padding: 0.75rem;
|
||
cursor: move;
|
||
transition: box-shadow 0.2s ease, transform 0.1s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.kanban-card:hover {
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.kanban-card.dragging {
|
||
opacity: 0.5;
|
||
transform: rotate(5deg);
|
||
}
|
||
|
||
.card-title {
|
||
font-weight: 600;
|
||
margin: 0 0 0.5rem 0;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.card-description {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
margin: 0 0 0.5rem 0;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.card-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.card-project {
|
||
background: #f3e8ff;
|
||
color: #7c3aed;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.card-assignee {
|
||
background: #e7f3ff;
|
||
color: #0066cc;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.card-task-link {
|
||
background: #f0f9ff;
|
||
color: #0284c7;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.card-due-date {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.card-due-date.overdue {
|
||
background: #fee2e2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.card-actions {
|
||
position: absolute;
|
||
top: 0.5rem;
|
||
right: 0.5rem;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.kanban-card:hover .card-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
.add-card-btn {
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
border: 2px dashed #dee2e6;
|
||
background: transparent;
|
||
color: #6c757d;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.add-card-btn:hover {
|
||
border-color: #007bff;
|
||
color: #007bff;
|
||
background: #f8f9ff;
|
||
}
|
||
|
||
.no-boards {
|
||
text-align: center;
|
||
padding: 4rem 2rem;
|
||
}
|
||
|
||
.no-boards-content {
|
||
max-width: 400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.no-boards h3 {
|
||
color: #666;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.no-boards p {
|
||
color: #999;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.drop-zone {
|
||
border: 2px dashed #007bff;
|
||
background: #f8f9ff;
|
||
border-radius: 6px;
|
||
min-height: 60px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.drop-zone-text {
|
||
color: #007bff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Column Management Styles */
|
||
.column-management {
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.add-column-section {
|
||
padding: 1rem;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.add-column-section h4 {
|
||
margin: 0 0 1rem 0;
|
||
color: #333;
|
||
}
|
||
|
||
.existing-columns-section h4 {
|
||
margin: 0 0 1rem 0;
|
||
color: #333;
|
||
}
|
||
|
||
.columns-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.column-item {
|
||
background: white;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
cursor: move;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.column-item:hover {
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.column-item.dragging {
|
||
opacity: 0.5;
|
||
transform: rotate(2deg);
|
||
}
|
||
|
||
.column-info {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.column-color-indicator {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 4px;
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 0 0 1px #dee2e6;
|
||
}
|
||
|
||
.column-details {
|
||
flex: 1;
|
||
}
|
||
|
||
.column-item-name {
|
||
font-weight: 600;
|
||
margin: 0 0 0.25rem 0;
|
||
}
|
||
|
||
.column-meta {
|
||
display: flex;
|
||
gap: 1rem;
|
||
font-size: 0.8rem;
|
||
color: #666;
|
||
}
|
||
|
||
.column-wip-limit {
|
||
background: #e7f3ff;
|
||
color: #0066cc;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.column-wip-warning {
|
||
background: #fff3cd !important;
|
||
color: #856404 !important;
|
||
}
|
||
|
||
.column-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.drag-handle {
|
||
cursor: move;
|
||
color: #6c757d;
|
||
font-size: 1.2rem;
|
||
padding: 0.25rem;
|
||
}
|
||
|
||
.drag-handle:hover {
|
||
color: #007bff;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #dee2e6;
|
||
}
|
||
|
||
#cancel-column-edit {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.form-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.kanban-header {
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.kanban-actions {
|
||
justify-content: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.kanban-actions .btn-md {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
font-size: 0.8rem;
|
||
padding: 0.4rem 0.8rem;
|
||
}
|
||
|
||
.board-selection {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.kanban-columns {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.kanban-column {
|
||
min-width: auto;
|
||
max-width: none;
|
||
}
|
||
|
||
.modal-actions, .form-actions {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-actions .btn-md, .form-actions .btn-md {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||
<script>
|
||
let currentBoard = null;
|
||
let sortableInstances = [];
|
||
const userRole = '{{ g.user.role.value }}';
|
||
const canManageColumns = ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'].includes(userRole);
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const boardSelect = document.getElementById('board-select');
|
||
const kanbanBoard = document.getElementById('kanban-board');
|
||
|
||
// Board selection handler
|
||
if (boardSelect) {
|
||
boardSelect.addEventListener('change', function() {
|
||
const boardId = this.value;
|
||
if (boardId) {
|
||
loadKanbanBoard(boardId);
|
||
if (canManageColumns) {
|
||
const manageBoardBtn = document.getElementById('manage-columns-btn');
|
||
if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
|
||
}
|
||
} else {
|
||
kanbanBoard.style.display = 'none';
|
||
if (canManageColumns) {
|
||
const manageBoardBtn = document.getElementById('manage-columns-btn');
|
||
if (manageBoardBtn) manageBoardBtn.style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check URL for board parameter or if a board is pre-selected
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const boardFromUrl = urlParams.get('board');
|
||
const selectedOption = boardSelect.querySelector('option[selected]');
|
||
|
||
if (boardFromUrl) {
|
||
boardSelect.value = boardFromUrl;
|
||
loadKanbanBoard(boardFromUrl);
|
||
if (canManageColumns) {
|
||
const manageBoardBtn = document.getElementById('manage-columns-btn');
|
||
if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
|
||
}
|
||
} else if (selectedOption && selectedOption.value) {
|
||
// Load pre-selected board (either from selected_board_id or default)
|
||
loadKanbanBoard(selectedOption.value);
|
||
if (canManageColumns) {
|
||
const manageBoardBtn = document.getElementById('manage-columns-btn');
|
||
if (manageBoardBtn) manageBoardBtn.style.display = 'inline-block';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Modal handlers
|
||
setupModals();
|
||
});
|
||
|
||
function loadKanbanBoard(boardId) {
|
||
console.log('Loading board:', boardId);
|
||
fetch(`/api/kanban/boards/${boardId}`)
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Board data received:', data);
|
||
if (data.success) {
|
||
currentBoard = data.board;
|
||
console.log('Current board set to:', currentBoard);
|
||
renderKanbanBoard(data.board);
|
||
document.getElementById('kanban-board').style.display = 'block';
|
||
} else {
|
||
console.error('Error loading board:', data.message);
|
||
alert('Error loading board: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Error loading board');
|
||
});
|
||
}
|
||
|
||
function renderKanbanBoard(board) {
|
||
const columnsContainer = document.getElementById('kanban-columns');
|
||
columnsContainer.innerHTML = '';
|
||
|
||
// Destroy existing sortable instances
|
||
sortableInstances.forEach(instance => instance.destroy());
|
||
sortableInstances = [];
|
||
|
||
board.columns.forEach(column => {
|
||
const columnElement = createColumnElement(column);
|
||
columnsContainer.appendChild(columnElement);
|
||
|
||
// Make column sortable for cards
|
||
const cardsContainer = columnElement.querySelector('.column-cards');
|
||
const sortable = new Sortable(cardsContainer, {
|
||
group: 'kanban-cards',
|
||
animation: 150,
|
||
ghostClass: 'sortable-ghost',
|
||
onEnd: function(evt) {
|
||
const cardId = evt.item.dataset.cardId;
|
||
const newColumnId = evt.to.closest('.kanban-column').dataset.columnId;
|
||
const newPosition = evt.newIndex + 1;
|
||
|
||
moveCard(cardId, newColumnId, newPosition);
|
||
}
|
||
});
|
||
sortableInstances.push(sortable);
|
||
});
|
||
}
|
||
|
||
function createColumnElement(column) {
|
||
const columnDiv = document.createElement('div');
|
||
columnDiv.className = 'kanban-column';
|
||
columnDiv.dataset.columnId = column.id;
|
||
|
||
const wipWarning = column.wip_limit && column.is_over_wip_limit ? 'wip-limit-warning' : '';
|
||
|
||
columnDiv.innerHTML = `
|
||
<div class="column-header" style="border-color: ${column.color};">
|
||
<h4 class="column-title" style="color: ${column.color};">${column.name}</h4>
|
||
<span class="column-count ${wipWarning}">
|
||
${column.card_count}${column.wip_limit ? `/${column.wip_limit}` : ''}
|
||
</span>
|
||
</div>
|
||
<div class="column-cards">
|
||
${column.cards.map(card => createCardHTML(card)).join('')}
|
||
</div>
|
||
<button class="add-card-btn" onclick="openCardModal(${column.id})">
|
||
+ Add Card
|
||
</button>
|
||
`;
|
||
|
||
return columnDiv;
|
||
}
|
||
|
||
function createCardHTML(card) {
|
||
const dueDateClass = card.due_date && new Date(card.due_date) < new Date() ? 'overdue' : '';
|
||
const cardStyle = card.color ? `background-color: ${card.color};` : '';
|
||
|
||
return `
|
||
<div class="kanban-card" data-card-id="${card.id}" style="${cardStyle}">
|
||
<div class="card-actions">
|
||
<button class="btn btn-xs btn-primary" onclick="editCard(${card.id})">Edit</button>
|
||
<button class="btn btn-xs btn-danger" onclick="deleteCard(${card.id})">×</button>
|
||
</div>
|
||
<div class="card-title">${card.title}</div>
|
||
${card.description ? `<div class="card-description">${card.description}</div>` : ''}
|
||
<div class="card-meta">
|
||
${card.project_code ? `<span class="card-project">[${card.project_code}]</span>` : ''}
|
||
${card.assigned_to ? `<span class="card-assignee">${card.assigned_to.username}</span>` : ''}
|
||
${card.task_name ? `<span class="card-task-link">${card.task_name}</span>` : ''}
|
||
${card.due_date ? `<span class="card-due-date ${dueDateClass}">${formatDate(card.due_date)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function setupModals() {
|
||
const boardModal = document.getElementById('board-modal');
|
||
const cardModal = document.getElementById('card-modal');
|
||
const boardForm = document.getElementById('board-form');
|
||
const cardForm = document.getElementById('card-form');
|
||
|
||
// Check if required elements exist
|
||
if (!cardModal) {
|
||
console.error('Card modal not found');
|
||
return;
|
||
}
|
||
if (!boardModal) {
|
||
console.error('Board modal not found');
|
||
return;
|
||
}
|
||
|
||
// Board modal
|
||
document.getElementById('create-board-btn')?.addEventListener('click', () => {
|
||
boardModal.style.display = 'block';
|
||
});
|
||
|
||
document.getElementById('create-first-board-btn')?.addEventListener('click', () => {
|
||
boardModal.style.display = 'block';
|
||
});
|
||
|
||
// Card modal
|
||
document.getElementById('add-card-btn')?.addEventListener('click', () => {
|
||
if (currentBoard && currentBoard.columns.length > 0) {
|
||
openCardModal(currentBoard.columns[0].id);
|
||
}
|
||
});
|
||
|
||
// Column management modal
|
||
document.getElementById('manage-columns-btn')?.addEventListener('click', () => {
|
||
openColumnManagement();
|
||
});
|
||
|
||
// Form submissions
|
||
if (boardForm) {
|
||
boardForm.addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
const formData = new FormData(this);
|
||
const data = Object.fromEntries(formData);
|
||
|
||
// Convert checkbox to boolean
|
||
const isDefaultCheckbox = document.getElementById('board-is-default');
|
||
data.is_default = isDefaultCheckbox ? isDefaultCheckbox.checked : false;
|
||
|
||
fetch('/api/kanban/boards', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
boardModal.style.display = 'none';
|
||
location.reload();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (cardForm) {
|
||
cardForm.addEventListener('submit', function(e){
|
||
e.preventDefault();
|
||
const formData = new FormData(this);
|
||
const cardId = formData.get('card_id');
|
||
const isEdit = cardId !== '';
|
||
|
||
const url = isEdit ? `/api/kanban/cards/${cardId}` : '/api/kanban/cards';
|
||
const method = isEdit ? 'PUT' : 'POST';
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(Object.fromEntries(formData))
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Card creation response:', data);
|
||
console.log('Current board at card creation:', currentBoard);
|
||
if (data.success) {
|
||
cardModal.style.display = 'none';
|
||
if (isEdit) {
|
||
// For edits, just reload the board to be safe
|
||
if (currentBoard && currentBoard.id) {
|
||
console.log('Reloading board after edit:', currentBoard.id);
|
||
loadKanbanBoard(currentBoard.id);
|
||
} else {
|
||
// Fallback: reload the currently selected board
|
||
const boardSelect = document.getElementById('board-select');
|
||
if (boardSelect && boardSelect.value) {
|
||
console.log('Fallback: reloading selected board after edit:', boardSelect.value);
|
||
loadKanbanBoard(boardSelect.value);
|
||
}
|
||
}
|
||
} else {
|
||
// For new cards, update the count immediately
|
||
const columnId = formData.get('column_id');
|
||
updateColumnCardCountAfterAdd(columnId);
|
||
if (currentBoard && currentBoard.id) {
|
||
console.log('Reloading board after new card:', currentBoard.id);
|
||
loadKanbanBoard(currentBoard.id);
|
||
} else {
|
||
// Fallback: reload the currently selected board
|
||
const boardSelect = document.getElementById('board-select');
|
||
if (boardSelect && boardSelect.value) {
|
||
console.log('Fallback: reloading selected board after new card:', boardSelect.value);
|
||
loadKanbanBoard(boardSelect.value);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Column form submission
|
||
const columnForm = document.getElementById('column-form');
|
||
columnForm.addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData(this);
|
||
const columnId = formData.get('column_id');
|
||
const isEdit = columnId !== '';
|
||
|
||
const url = isEdit ? `/api/kanban/columns/${columnId}` : '/api/kanban/columns';
|
||
const method = isEdit ? 'PUT' : 'POST';
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(Object.fromEntries(formData))
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
loadKanbanBoard(currentBoard.id);
|
||
if (!isEdit) {
|
||
// Reset form for adding more columns
|
||
columnForm.reset();
|
||
document.getElementById('column-color').value = '#6c757d';
|
||
} else {
|
||
cancelColumnEdit();
|
||
}
|
||
loadColumnsList();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Cancel column edit
|
||
document.getElementById('cancel-column-edit').addEventListener('click', () => {
|
||
cancelColumnEdit();
|
||
});
|
||
|
||
// Close buttons
|
||
document.querySelectorAll('.close').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const modal = this.closest('.modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
});
|
||
});
|
||
|
||
const cancelBoardBtn = document.getElementById('cancel-board');
|
||
if (cancelBoardBtn) {
|
||
cancelBoardBtn.addEventListener('click', () => {
|
||
boardModal.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
const cancelCardBtn = document.getElementById('cancel-card');
|
||
if (cancelCardBtn) {
|
||
cancelCardBtn.addEventListener('click', () => {
|
||
cardModal.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
// Close modals when clicking outside
|
||
const columnModal = document.getElementById('column-modal');
|
||
window.addEventListener('click', function(event) {
|
||
if (event.target === boardModal) {
|
||
boardModal.style.display = 'none';
|
||
}
|
||
if (event.target === cardModal) {
|
||
cardModal.style.display = 'none';
|
||
}
|
||
if (event.target === columnModal) {
|
||
columnModal.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCardModal(columnId, cardData = null) {
|
||
const modal = document.getElementById('card-modal');
|
||
const form = document.getElementById('card-form');
|
||
const isEdit = cardData !== null;
|
||
|
||
document.getElementById('card-modal-title').textContent = isEdit ? 'Edit Card' : 'Create Card';
|
||
document.getElementById('card-column-id').value = columnId;
|
||
|
||
if (isEdit) {
|
||
document.getElementById('card-id').value = cardData.id;
|
||
document.getElementById('card-title').value = cardData.title;
|
||
document.getElementById('card-description').value = cardData.description || '';
|
||
document.getElementById('card-assigned-to').value = cardData.assigned_to?.id || '';
|
||
document.getElementById('card-task').value = cardData.task_id || '';
|
||
document.getElementById('card-due-date').value = cardData.due_date || '';
|
||
document.getElementById('card-color').value = cardData.color || '#ffffff';
|
||
} else {
|
||
form.reset();
|
||
document.getElementById('card-id').value = '';
|
||
document.getElementById('card-column-id').value = columnId;
|
||
document.getElementById('card-color').value = '#ffffff';
|
||
}
|
||
|
||
modal.style.display = 'block';
|
||
}
|
||
|
||
function editCard(cardId) {
|
||
// Find card data from current board
|
||
let cardData = null;
|
||
currentBoard.columns.forEach(column => {
|
||
const card = column.cards.find(c => c.id === cardId);
|
||
if (card) {
|
||
cardData = card;
|
||
cardData.columnId = column.id;
|
||
}
|
||
});
|
||
|
||
if (cardData) {
|
||
openCardModal(cardData.columnId, cardData);
|
||
}
|
||
}
|
||
|
||
function deleteCard(cardId) {
|
||
if (confirm('Are you sure you want to delete this card?')) {
|
||
// Find which column the card is in before deleting
|
||
let cardColumnId = null;
|
||
currentBoard.columns.forEach(column => {
|
||
const card = column.cards.find(c => c.id == cardId);
|
||
if (card) {
|
||
cardColumnId = column.id;
|
||
}
|
||
});
|
||
|
||
fetch(`/api/kanban/cards/${cardId}`, {
|
||
method: 'DELETE'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// Update the column count immediately
|
||
if (cardColumnId) {
|
||
updateColumnCardCountAfterDelete(cardColumnId);
|
||
}
|
||
loadKanbanBoard(currentBoard.id);
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function moveCard(cardId, columnId, position) {
|
||
// Find the card in the current board data
|
||
let movedCard = null;
|
||
let oldColumnId = null;
|
||
|
||
currentBoard.columns.forEach(column => {
|
||
const cardIndex = column.cards.findIndex(c => c.id == cardId);
|
||
if (cardIndex !== -1) {
|
||
movedCard = column.cards[cardIndex];
|
||
oldColumnId = column.id;
|
||
}
|
||
});
|
||
|
||
fetch(`/api/kanban/cards/${cardId}/move`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
column_id: parseInt(columnId),
|
||
position: position
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// Update card counts in the UI immediately
|
||
updateColumnCardCounts(oldColumnId, columnId);
|
||
} else {
|
||
alert('Error moving card: ' + data.message);
|
||
loadKanbanBoard(currentBoard.id); // Reload to reset positions
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateColumnCardCounts(oldColumnId, newColumnId) {
|
||
// Update the old column count (decrease by 1)
|
||
if (oldColumnId && oldColumnId != newColumnId) {
|
||
const oldColumnElement = document.querySelector(`[data-column-id="${oldColumnId}"]`);
|
||
if (oldColumnElement) {
|
||
const oldCountElement = oldColumnElement.querySelector('.column-count');
|
||
if (oldCountElement) {
|
||
const oldCount = parseInt(oldCountElement.textContent.split('/')[0]);
|
||
const wipLimit = oldCountElement.textContent.includes('/') ? '/' + oldCountElement.textContent.split('/')[1] : '';
|
||
oldCountElement.textContent = (oldCount - 1) + wipLimit;
|
||
|
||
// Update the current board data
|
||
const oldColumn = currentBoard.columns.find(c => c.id == oldColumnId);
|
||
if (oldColumn) {
|
||
oldColumn.card_count = oldCount - 1;
|
||
// Check if still over WIP limit
|
||
if (oldColumn.wip_limit && oldColumn.card_count <= oldColumn.wip_limit) {
|
||
oldCountElement.classList.remove('wip-limit-warning');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update the new column count (increase by 1)
|
||
const newColumnElement = document.querySelector(`[data-column-id="${newColumnId}"]`);
|
||
if (newColumnElement) {
|
||
const newCountElement = newColumnElement.querySelector('.column-count');
|
||
if (newCountElement) {
|
||
const newCount = parseInt(newCountElement.textContent.split('/')[0]);
|
||
const wipLimit = newCountElement.textContent.includes('/') ? '/' + newCountElement.textContent.split('/')[1] : '';
|
||
newCountElement.textContent = (newCount + 1) + wipLimit;
|
||
|
||
// Update the current board data
|
||
const newColumn = currentBoard.columns.find(c => c.id == newColumnId);
|
||
if (newColumn) {
|
||
newColumn.card_count = newCount + 1;
|
||
// Check if now over WIP limit
|
||
if (newColumn.wip_limit && newColumn.card_count > newColumn.wip_limit) {
|
||
newCountElement.classList.add('wip-limit-warning');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateColumnCardCountAfterAdd(columnId) {
|
||
const columnElement = document.querySelector(`[data-column-id="${columnId}"]`);
|
||
if (columnElement) {
|
||
const countElement = columnElement.querySelector('.column-count');
|
||
if (countElement) {
|
||
const currentCount = parseInt(countElement.textContent.split('/')[0]);
|
||
const wipLimit = countElement.textContent.includes('/') ? '/' + countElement.textContent.split('/')[1] : '';
|
||
countElement.textContent = (currentCount + 1) + wipLimit;
|
||
|
||
// Update the current board data
|
||
const column = currentBoard.columns.find(c => c.id == columnId);
|
||
if (column) {
|
||
column.card_count = currentCount + 1;
|
||
// Check if now over WIP limit
|
||
if (column.wip_limit && column.card_count > column.wip_limit) {
|
||
countElement.classList.add('wip-limit-warning');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateColumnCardCountAfterDelete(columnId) {
|
||
const columnElement = document.querySelector(`[data-column-id="${columnId}"]`);
|
||
if (columnElement) {
|
||
const countElement = columnElement.querySelector('.column-count');
|
||
if (countElement) {
|
||
const currentCount = parseInt(countElement.textContent.split('/')[0]);
|
||
const wipLimit = countElement.textContent.includes('/') ? '/' + countElement.textContent.split('/')[1] : '';
|
||
countElement.textContent = (currentCount - 1) + wipLimit;
|
||
|
||
// Update the current board data
|
||
const column = currentBoard.columns.find(c => c.id == columnId);
|
||
if (column) {
|
||
column.card_count = currentCount - 1;
|
||
// Check if no longer over WIP limit
|
||
if (column.wip_limit && column.card_count <= column.wip_limit) {
|
||
countElement.classList.remove('wip-limit-warning');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Column Management Functions
|
||
function openColumnManagement() {
|
||
if (!currentBoard) {
|
||
alert('Please select a board first');
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('column-modal');
|
||
const form = document.getElementById('column-form');
|
||
|
||
// Reset form
|
||
form.reset();
|
||
document.getElementById('column-board-id').value = currentBoard.id;
|
||
document.getElementById('column-id').value = '';
|
||
document.getElementById('column-color').value = '#6c757d';
|
||
document.getElementById('save-column-btn').textContent = 'Add Column';
|
||
document.getElementById('cancel-column-edit').style.display = 'none';
|
||
|
||
// Load existing columns
|
||
loadColumnsList();
|
||
|
||
modal.style.display = 'block';
|
||
}
|
||
|
||
function loadColumnsList() {
|
||
const columnsList = document.getElementById('columns-list');
|
||
columnsList.innerHTML = '';
|
||
|
||
if (!currentBoard || !currentBoard.columns) {
|
||
return;
|
||
}
|
||
|
||
currentBoard.columns.forEach(column => {
|
||
const columnElement = createColumnListItem(column);
|
||
columnsList.appendChild(columnElement);
|
||
});
|
||
|
||
// Make columns sortable
|
||
if (window.columnSortable) {
|
||
window.columnSortable.destroy();
|
||
}
|
||
|
||
window.columnSortable = new Sortable(columnsList, {
|
||
animation: 150,
|
||
ghostClass: 'sortable-ghost',
|
||
handle: '.drag-handle',
|
||
onEnd: function(evt) {
|
||
const columnId = evt.item.dataset.columnId;
|
||
const newPosition = evt.newIndex + 1;
|
||
moveColumn(columnId, newPosition);
|
||
}
|
||
});
|
||
}
|
||
|
||
function createColumnListItem(column) {
|
||
const div = document.createElement('div');
|
||
div.className = 'column-item';
|
||
div.dataset.columnId = column.id;
|
||
|
||
const wipClass = column.wip_limit && column.is_over_wip_limit ? 'column-wip-warning' : 'column-wip-limit';
|
||
|
||
div.innerHTML = `
|
||
<div class="column-info">
|
||
<div class="drag-handle">⋮⋮</div>
|
||
<div class="column-color-indicator" style="background-color: ${column.color};"></div>
|
||
<div class="column-details">
|
||
<div class="column-item-name">${column.name}</div>
|
||
<div class="column-meta">
|
||
<span>${column.card_count} cards</span>
|
||
${column.wip_limit ? `<span class="${wipClass}">WIP: ${column.wip_limit}</span>` : ''}
|
||
${column.description ? `<span>${column.description}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column-actions">
|
||
<button class="btn btn-xs btn-primary" onclick="editColumn(${column.id})">Edit</button>
|
||
<button class="btn btn-xs btn-danger" onclick="deleteColumn(${column.id})">Delete</button>
|
||
</div>
|
||
`;
|
||
|
||
return div;
|
||
}
|
||
|
||
function editColumn(columnId) {
|
||
const column = currentBoard.columns.find(c => c.id === columnId);
|
||
if (!column) return;
|
||
|
||
const form = document.getElementById('column-form');
|
||
document.getElementById('column-id').value = column.id;
|
||
document.getElementById('column-name').value = column.name;
|
||
document.getElementById('column-description').value = column.description || '';
|
||
document.getElementById('column-color').value = column.color;
|
||
document.getElementById('column-wip-limit').value = column.wip_limit || '';
|
||
|
||
document.getElementById('save-column-btn').textContent = 'Update Column';
|
||
document.getElementById('cancel-column-edit').style.display = 'inline-block';
|
||
}
|
||
|
||
function cancelColumnEdit() {
|
||
const form = document.getElementById('column-form');
|
||
form.reset();
|
||
document.getElementById('column-id').value = '';
|
||
document.getElementById('column-color').value = '#6c757d';
|
||
document.getElementById('save-column-btn').textContent = 'Add Column';
|
||
document.getElementById('cancel-column-edit').style.display = 'none';
|
||
}
|
||
|
||
function deleteColumn(columnId) {
|
||
const column = currentBoard.columns.find(c => c.id === columnId);
|
||
if (!column) return;
|
||
|
||
if (confirm(`Are you sure you want to delete the column "${column.name}"? This action cannot be undone.`)) {
|
||
fetch(`/api/kanban/columns/${columnId}`, {
|
||
method: 'DELETE'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
loadKanbanBoard(currentBoard.id);
|
||
loadColumnsList();
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function moveColumn(columnId, newPosition) {
|
||
fetch(`/api/kanban/columns/${columnId}/move`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
position: newPosition
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
loadKanbanBoard(currentBoard.id);
|
||
} else {
|
||
alert('Error moving column: ' + data.message);
|
||
loadColumnsList(); // Reload to reset positions
|
||
}
|
||
});
|
||
}
|
||
|
||
function formatDate(dateString) {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString();
|
||
}
|
||
</script>
|
||
|
||
{% endblock %} |