Files
TimeTrack/templates/project_kanban.html

1377 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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">&times;</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">&times;</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">&times;</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 %}