Remove obsolete Kanban parts.

This commit is contained in:
2025-07-04 21:55:54 +02:00
parent 43b99a0c3e
commit 1fe3f18bbd
11 changed files with 625 additions and 3437 deletions

947
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,8 @@ try:
from app import app, db
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget,
WidgetTemplate)
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent,
WidgetType, UserDashboard, DashboardWidget, WidgetTemplate)
from werkzeug.security import generate_password_hash
FLASK_AVAILABLE = True
except ImportError:
@@ -74,7 +73,6 @@ def run_all_migrations(db_path=None):
migrate_work_config_data(db_path)
migrate_task_system(db_path)
migrate_system_events(db_path)
migrate_kanban_system(db_path)
migrate_dashboard_system(db_path)
if FLASK_AVAILABLE:
@@ -886,107 +884,6 @@ def create_all_tables(cursor):
print("All tables created")
def migrate_kanban_system(db_file=None):
"""Migrate to add Kanban board system."""
db_path = get_db_path(db_file)
print(f"Migrating Kanban system in {db_path}...")
if not os.path.exists(db_path):
print(f"Database file {db_path} does not exist. Run basic migration first.")
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if kanban_board table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_board'")
if cursor.fetchone():
print("Kanban tables already exist. Skipping migration.")
return True
print("Creating Kanban board tables...")
# Create kanban_board table
cursor.execute("""
CREATE TABLE kanban_board (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
project_id INTEGER NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_default BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES project (id),
FOREIGN KEY (created_by_id) REFERENCES user (id),
UNIQUE(project_id, name)
)
""")
# Create kanban_column table
cursor.execute("""
CREATE TABLE kanban_column (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
position INTEGER NOT NULL,
color VARCHAR(7) DEFAULT '#6c757d',
wip_limit INTEGER,
is_active BOOLEAN DEFAULT 1,
board_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES kanban_board (id),
UNIQUE(board_id, name)
)
""")
# Create kanban_card table
cursor.execute("""
CREATE TABLE kanban_card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
position INTEGER NOT NULL,
color VARCHAR(7),
is_active BOOLEAN DEFAULT 1,
column_id INTEGER NOT NULL,
task_id INTEGER,
assigned_to_id INTEGER,
due_date DATE,
completed_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (column_id) REFERENCES kanban_column (id),
FOREIGN KEY (task_id) REFERENCES task (id),
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
FOREIGN KEY (created_by_id) REFERENCES user (id)
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_kanban_board_project ON kanban_board(project_id)")
cursor.execute("CREATE INDEX idx_kanban_column_board ON kanban_column(board_id)")
cursor.execute("CREATE INDEX idx_kanban_card_column ON kanban_card(column_id)")
cursor.execute("CREATE INDEX idx_kanban_card_task ON kanban_card(task_id)")
cursor.execute("CREATE INDEX idx_kanban_card_assigned ON kanban_card(assigned_to_id)")
conn.commit()
print("Kanban system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Kanban system migration: {e}")
conn.rollback()
raise
finally:
conn.close()
def migrate_dashboard_system(db_file=None):
"""Migrate to add Dashboard widget system."""
db_path = get_db_path(db_file)
@@ -1091,7 +988,6 @@ def migrate_dashboard_system(db_file=None):
# Task Management Widgets
('assigned_tasks', 'My Tasks', 'Tasks assigned to me', '', 2, 2, '{}', 'Team Member', 'Tasks'),
('task_priority', 'Priority Matrix', 'Tasks organized by priority', '🔥', 2, 2, '{}', 'Team Member', 'Tasks'),
('kanban_summary', 'Kanban Overview', 'Summary of Kanban boards', '📋', 3, 1, '{}', 'Team Member', 'Tasks'),
('task_trends', 'Task Trends', 'Task completion trends', '📉', 2, 1, '{}', 'Team Member', 'Tasks'),
# Analytics Widgets
@@ -1147,8 +1043,6 @@ def main():
help='Run only basic table migrations')
parser.add_argument('--system-events', '-s', action='store_true',
help='Run only system events migration')
parser.add_argument('--kanban', '-k', action='store_true',
help='Run only Kanban system migration')
parser.add_argument('--dashboard', '--dash', action='store_true',
help='Run only dashboard system migration')
@@ -1183,8 +1077,6 @@ def main():
elif args.system_events:
migrate_system_events(db_path)
elif args.kanban:
migrate_kanban_system(db_path)
elif args.dashboard:
migrate_dashboard_system(db_path)

131
models.py
View File

@@ -726,136 +726,6 @@ class SystemEvent(db.Model):
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
}
# Kanban Board models
class KanbanBoard(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
# Company association for multi-tenancy (removed project-specific constraint)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Board settings
is_active = db.Column(db.Boolean, default=True)
is_default = db.Column(db.Boolean, default=False) # Default board for company
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
company = db.relationship('Company', backref='kanban_boards')
created_by = db.relationship('User', foreign_keys=[created_by_id])
columns = db.relationship('KanbanColumn', backref='board', lazy=True, cascade='all, delete-orphan', order_by='KanbanColumn.position')
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_kanban_board_name_per_company'),)
def __repr__(self):
return f'<KanbanBoard {self.name}>'
class KanbanColumn(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
# Column settings
position = db.Column(db.Integer, nullable=False) # Order in board
color = db.Column(db.String(7), default='#6c757d') # Hex color
wip_limit = db.Column(db.Integer, nullable=True) # Work in progress limit
is_active = db.Column(db.Boolean, default=True)
# Board association
board_id = db.Column(db.Integer, db.ForeignKey('kanban_board.id'), nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
cards = db.relationship('KanbanCard', backref='column', lazy=True, cascade='all, delete-orphan', order_by='KanbanCard.position')
# Unique constraint per board
__table_args__ = (db.UniqueConstraint('board_id', 'name', name='uq_kanban_column_name_per_board'),)
def __repr__(self):
return f'<KanbanColumn {self.name}>'
@property
def card_count(self):
"""Get number of cards in this column"""
return len([card for card in self.cards if card.is_active])
@property
def is_over_wip_limit(self):
"""Check if column is over WIP limit"""
if not self.wip_limit:
return False
return self.card_count > self.wip_limit
class KanbanCard(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Card settings
position = db.Column(db.Integer, nullable=False) # Order in column
color = db.Column(db.String(7), nullable=True) # Optional custom color
is_active = db.Column(db.Boolean, default=True)
# Column association
column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False)
# Project context for cross-project support
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Optional task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
# Card assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Card dates
due_date = db.Column(db.Date, nullable=True)
completed_date = db.Column(db.Date, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
project = db.relationship('Project', backref='kanban_cards')
task = db.relationship('Task', backref='kanban_cards')
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_kanban_cards')
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __repr__(self):
return f'<KanbanCard {self.title}>'
def can_user_access(self, user):
"""Check if a user can access this card"""
# Check company membership first
if self.column.board.company_id != user.company_id:
return False
# If card has project context, check project permissions
if self.project_id:
return self.project.is_user_allowed(user)
# If no project context, allow access to anyone in the company
return True
@property
def project_code(self):
"""Get project code for display purposes"""
return self.project.code if self.project else None
@property
def project_name(self):
"""Get project name for display purposes"""
return self.project.name if self.project else None
# Sprint Management System
class SprintStatus(enum.Enum):
@@ -984,7 +854,6 @@ class WidgetType(enum.Enum):
# Task Management Widgets
ASSIGNED_TASKS = "assigned_tasks"
TASK_PRIORITY = "task_priority"
KANBAN_SUMMARY = "kanban_summary"
TASK_TRENDS = "task_trends"
# Analytics Widgets

View File

@@ -2041,61 +2041,6 @@ input[type="time"]::-webkit-datetime-edit {
gap: 0.5rem;
}
/* Kanban Common Styles */
.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;
}
.kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.kanban-column {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
min-height: 500px;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ddd;
}
.column-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.column-content {
min-height: 400px;
padding: 0.5rem 0;
}
.task-count {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
}
/* Card Components - Enhanced */
.card {
@@ -2315,9 +2260,6 @@ input[type="time"]::-webkit-datetime-edit {
grid-template-columns: repeat(2, 1fr);
}
.kanban-board {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
@@ -2339,9 +2281,6 @@ input[type="time"]::-webkit-datetime-edit {
grid-template-columns: 1fr;
}
.kanban-board {
grid-template-columns: 1fr;
}
.filter-row {
flex-direction: column;

View File

@@ -93,7 +93,6 @@
<td class="actions">
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-sm btn-success">Kanban</a>
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this project?')">

View File

@@ -169,32 +169,6 @@
</div>
{% endif %}
<!-- Kanban Boards -->
{% if kanban_boards %}
<div class="table-section">
<h3>📋 Kanban Boards ({{ kanban_boards|length }})</h3>
<table class="data-table">
<thead>
<tr>
<th>Board Name</th>
<th>Columns</th>
<th>Cards</th>
<th>Created By</th>
</tr>
</thead>
<tbody>
{% for board in kanban_boards %}
<tr>
<td>{{ board.name }}</td>
<td>{{ board.columns|length }}</td>
<td>{{ board.columns|map(attribute='cards')|map('length')|sum }}</td>
<td>{{ board.created_by.username }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Time Entries -->
{% if time_entries_count > 0 %}

View File

@@ -116,15 +116,6 @@
</div>
</div>
</div>
<div class="widget-option" data-type="kanban_summary">
<div class="widget-preview">
<i class="fas fa-th-large"></i>
<div class="widget-info">
<h5>Kanban Summary</h5>
<p>Mini kanban board for quick task management</p>
</div>
</div>
</div>
</div>
</div>
@@ -535,33 +526,6 @@
color: #333;
}
/* Kanban Summary Widget */
.kanban-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 0.5rem;
padding: 0.5rem;
}
.kanban-column-summary {
text-align: center;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #007bff;
}
.column-name {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.25rem;
}
.column-count {
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
/* Project Progress Widget */
.project-progress-list {
@@ -887,7 +851,6 @@ const widgetSizes = {
'break_reminder': 'large',
'project_progress': 'wide',
'task_priority': 'medium',
'kanban_summary': 'large',
'productivity_metrics': 'medium',
'time_distribution': 'wide',
'team_activity': 'wide',
@@ -1034,8 +997,6 @@ function renderWidgetContent(widget) {
return renderAssignedTasksWidget(widget);
case 'task_priority':
return renderTaskPriorityWidget(widget);
case 'kanban_summary':
return renderKanbanSummaryWidget(widget);
case 'productivity_metrics':
return renderProductivityMetricsWidget(widget);
case 'time_distribution':
@@ -1154,14 +1115,6 @@ function renderTaskPriorityWidget(widget) {
`;
}
function renderKanbanSummaryWidget(widget) {
loadWidgetData(widget.id);
return `
<div class="kanban-summary-widget" id="widget-content-${widget.id}">
<div class="widget-loading">Loading kanban...</div>
</div>
`;
}
function renderProductivityMetricsWidget(widget) {
loadWidgetData(widget.id);
@@ -1294,7 +1247,6 @@ function showConfigurationModal() {
'project_progress': 'Project Progress',
'assigned_tasks': 'Assigned Tasks',
'task_priority': 'Task Priority',
'kanban_summary': 'Kanban Summary',
'productivity_metrics': 'Productivity Metrics',
'time_distribution': 'Time Distribution'
};
@@ -1536,9 +1488,6 @@ function updateWidgetContent(widgetId, data) {
} else if (data.priority_tasks) {
// Update task priority widget
updateTaskPriorityWidget(widgetId, data.priority_tasks);
} else if (data.kanban_boards) {
// Update kanban summary widget
updateKanbanSummaryWidget(widgetId, data.kanban_boards);
} else if (data.project_progress) {
// Update project progress widget
updateProjectProgressWidget(widgetId, data.project_progress);
@@ -1698,26 +1647,6 @@ function updateTaskPriorityWidget(widgetId, priorityTasks) {
contentElement.innerHTML = tasksHtml;
}
function updateKanbanSummaryWidget(widgetId, kanbanBoards) {
const contentElement = document.getElementById(`widget-content-${widgetId}`);
if (!contentElement) return;
let kanbanHtml = '<div class="kanban-summary-list">';
kanbanBoards.forEach(board => {
kanbanHtml += `
<div class="kanban-board-item">
<div class="board-name">${board.name}</div>
<div class="board-project">${board.project_name}</div>
<div class="board-stats">
<span class="stat">${board.total_cards} cards</span>
<span class="stat">${board.columns} columns</span>
</div>
</div>
`;
});
kanbanHtml += '</div>';
contentElement.innerHTML = kanbanHtml;
}
function updateProjectProgressWidget(widgetId, projectProgress) {
const contentElement = document.getElementById(`widget-content-${widgetId}`);

View File

@@ -1,695 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="page-container kanban-overview-container">
<div class="page-header overview-header">
<h2>Unified Kanban Boards</h2>
<p class="overview-description">Organize tasks from any project on shared company-wide boards</p>
</div>
{% if create_board %}
<!-- Create Board Form -->
<div class="create-board-form">
<div class="form-header">
<h3>Create New Kanban Board</h3>
<button type="button" id="cancel-create-board" class="btn btn-secondary">Cancel</button>
</div>
<form id="create-board-form" class="board-form">
<div class="form-group">
<label for="board-name">Board Name <span class="required">*</span></label>
<input type="text" id="board-name" name="name" required placeholder="Enter board name">
</div>
<div class="form-group">
<label for="board-description">Description</label>
<textarea id="board-description" name="description" placeholder="Optional description"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="board-is-default" name="is_default">
Set as default board
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Board</button>
</div>
</form>
</div>
{% endif %}
{% if boards %}
<div class="boards-grid">
{% for board in boards %}
<div class="card board-card">
<div class="card-header board-header">
<div class="board-info">
<h3 class="board-name">
{{ board.name }}
{% if board.is_default %}
<span class="default-badge">Default</span>
{% endif %}
</h3>
{% if board.description %}
<p class="board-description">{{ board.description }}</p>
{% endif %}
</div>
<div class="board-actions">
<a href="{{ url_for('kanban_overview') }}?board={{ board.id }}" class="btn btn-primary">Open Board</a>
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
<button class="btn btn-secondary" onclick="editBoard({{ board.id }})">Edit</button>
{% endif %}
</div>
</div>
<div class="board-stats-section">
<div class="stat-grid">
<div class="stat-item">
<span class="stat-number">{{ board.columns|length }}</span>
<span class="stat-label">Columns</span>
</div>
<div class="stat-item">
{% set board_cards = 0 %}
{% for column in board.columns %}
{% set board_cards = board_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %}
<span class="stat-number">{{ board_cards }}</span>
<span class="stat-label">Cards</span>
</div>
<div class="stat-item">
{% set project_contexts = [] %}
{% for column in board.columns %}
{% for card in column.cards %}
{% if card.is_active and card.project_id and card.project_id not in project_contexts %}
{% set _ = project_contexts.append(card.project_id) %}
{% endif %}
{% endfor %}
{% endfor %}
<span class="stat-number">{{ project_contexts|length }}</span>
<span class="stat-label">Projects</span>
</div>
</div>
</div>
<!-- Quick Board Preview -->
<div class="board-preview">
{% if board.columns %}
<h5>Board Preview</h5>
<div class="preview-columns">
{% for column in board.columns %}
{% if loop.index <= 4 %}
<div class="preview-column" style="border-top: 3px solid {{ column.color }};">
<div class="preview-column-header">
<span class="preview-column-name">{{ column.name }}</span>
<span class="preview-card-count">{{ column.cards|selectattr('is_active')|list|length }}</span>
</div>
<div class="preview-cards">
{% set active_cards = column.cards|selectattr('is_active')|list %}
{% for card in active_cards %}
{% if loop.index <= 3 %}
<div class="preview-card" {% if card.color %}style="background-color: {{ card.color }};"{% endif %}>
<div class="preview-card-title">
{% if card.project %}
<span class="preview-card-project">[{{ card.project.code }}]</span>
{% endif %}
{% if card.title|length > 25 %}
{{ card.title|truncate(25, True) }}
{% else %}
{{ card.title }}
{% endif %}
</div>
{% if card.assigned_to %}
<div class="preview-card-assignee">{{ card.assigned_to.username }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% if active_cards|length > 3 %}
<div class="preview-more">+{{ active_cards|length - 3 }} more</div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% if board.columns|length > 4 %}
<div class="preview-more-columns">
+{{ board.columns|length - 4 }} more columns
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-card">
<h3>{{ boards|length }}</h3>
<p>Company Boards</p>
</div>
<div class="stat-card">
{% set total_cards = 0 %}
{% for board in boards %}
{% for column in board.columns %}
{% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %}
{% endfor %}
<h3>{{ total_cards }}</h3>
<p>Total Cards</p>
</div>
<div class="stat-card">
{% set total_projects = [] %}
{% for board in boards %}
{% for column in board.columns %}
{% for card in column.cards %}
{% if card.is_active and card.project_id and card.project_id not in total_projects %}
{% set _ = total_projects.append(card.project_id) %}
{% endif %}
{% endfor %}
{% endfor %}
{% endfor %}
<h3>{{ total_projects|length }}</h3>
<p>Active Projects</p>
</div>
</div>
{% else %}
<!-- No Kanban Boards -->
<div class="no-kanban">
<div class="no-kanban-content">
<div class="no-kanban-icon">📋</div>
<h3>No Kanban Boards Yet</h3>
<p>Create unified boards to organize tasks from any project.</p>
<div class="getting-started">
<h4>Getting Started:</h4>
<ol>
<li>Click the <strong>"Create Board"</strong> button</li>
<li>Set up columns for your workflow (To Do, In Progress, Done)</li>
<li>Add cards from any project to organize your work</li>
</ol>
</div>
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
<button id="create-first-unified-board" class="btn btn-primary">Create First Board</button>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
/* Container and header styles now inherited from common styles.css */
.overview-header {
text-align: center;
}
.overview-description {
color: #666;
margin: 0;
}
.boards-grid {
display: grid;
gap: 2rem;
margin-bottom: 2rem;
}
.board-card {
/* Card styling now inherited from common .card class */
border-radius: 12px;
}
.board-header {
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.board-info {
flex: 1;
}
.board-name {
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.board-description {
color: #666;
margin: 0.5rem 0 0 0;
font-size: 0.9rem;
}
.board-actions {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.board-stats-section {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.stat-number {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #007bff;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #666;
font-weight: 500;
}
.boards-section h4 {
margin: 0 0 1rem 0;
color: #333;
}
.boards-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.board-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.board-item:hover {
background: #e7f3ff;
border-color: #007bff;
transform: translateY(-1px);
}
.board-info {
flex: 1;
}
.board-name {
font-weight: 600;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.default-badge {
background: #28a745;
color: white;
padding: 0.15rem 0.4rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
}
.board-description {
color: #666;
font-size: 0.9rem;
}
.board-stats {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
color: #666;
text-align: right;
}
.board-preview {
padding: 1.5rem;
}
.board-preview h5 {
margin: 0 0 1rem 0;
color: #333;
}
.preview-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.preview-column {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
}
.preview-column-header {
padding: 0.75rem;
background: white;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-column-name {
font-weight: 600;
font-size: 0.9rem;
}
.preview-card-count {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 500;
}
.preview-cards {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 0.5rem;
font-size: 0.8rem;
}
.preview-card-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.preview-card-project {
background: #f3e8ff;
color: #7c3aed;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.6rem;
font-weight: 600;
margin-right: 0.25rem;
}
.preview-card-assignee {
color: #666;
font-size: 0.7rem;
}
.preview-more {
color: #666;
font-size: 0.8rem;
text-align: center;
padding: 0.25rem;
}
.preview-more-columns {
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 0.9rem;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1rem;
}
.quick-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dee2e6;
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #007bff;
}
.stat-card p {
margin: 0;
color: #666;
font-weight: 500;
}
.no-kanban {
text-align: center;
padding: 4rem 2rem;
}
.no-kanban-content {
max-width: 500px;
margin: 0 auto;
}
.no-kanban-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.no-kanban h3 {
color: #666;
margin-bottom: 1rem;
}
.no-kanban p {
color: #999;
margin-bottom: 2rem;
}
.getting-started {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: left;
}
.getting-started h4 {
margin: 0 0 1rem 0;
color: #333;
}
.getting-started ol {
margin: 0;
padding-left: 1.5rem;
}
.getting-started li {
margin-bottom: 0.5rem;
color: #666;
}
.create-board-form {
background: white;
border: 1px solid #dee2e6;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.form-header h3 {
margin: 0;
color: #333;
}
.board-form {
max-width: 600px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.form-group textarea {
height: 80px;
resize: vertical;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-actions {
margin-top: 1.5rem;
}
.required {
color: #dc3545;
}
.category-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
@media (max-width: 768px) {
.board-header {
flex-direction: column;
gap: 1rem;
}
.board-actions {
width: 100%;
justify-content: flex-start;
}
.preview-columns {
grid-template-columns: 1fr;
}
.quick-stats {
grid-template-columns: 1fr;
}
.stat-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
<script>
function openBoard(boardId) {
window.location.href = `/kanban?board=${boardId}`;
}
function editBoard(boardId) {
// Implement board editing functionality
alert('Board editing functionality to be implemented');
}
// Handle create first board button
document.addEventListener('DOMContentLoaded', function() {
const createFirstBoardBtn = document.getElementById('create-first-unified-board');
if (createFirstBoardBtn) {
createFirstBoardBtn.addEventListener('click', function() {
// Redirect to a page where they can create a board
window.location.href = '/kanban?create=true';
});
}
// Handle cancel create board
const cancelCreateBoardBtn = document.getElementById('cancel-create-board');
if (cancelCreateBoardBtn) {
cancelCreateBoardBtn.addEventListener('click', function() {
window.location.href = '/kanban';
});
}
// Handle create board form submission
const createBoardForm = document.getElementById('create-board-form');
if (createBoardForm) {
createBoardForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(createBoardForm);
const data = {
name: formData.get('name'),
description: formData.get('description'),
is_default: formData.get('is_default') === 'on'
};
fetch('/api/kanban/boards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Redirect to the kanban overview page
window.location.href = '/kanban';
} else {
alert('Error creating board: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating board. Please try again.');
});
});
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -109,11 +109,21 @@
<div class="form-row">
<div class="form-group">
<label for="sprint-start-date">Start Date *</label>
<input type="date" id="sprint-start-date" required>
<div class="hybrid-date-input">
<input type="date" id="sprint-start-date-native" class="date-input-native" required>
<input type="text" id="sprint-start-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-start-date')" title="Open calendar">📅</button>
</div>
<div class="date-error" id="sprint-start-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
</div>
<div class="form-group">
<label for="sprint-end-date">End Date *</label>
<input type="date" id="sprint-end-date" required>
<div class="hybrid-date-input">
<input type="date" id="sprint-end-date-native" class="date-input-native" required>
<input type="text" id="sprint-end-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-end-date')" title="Open calendar">📅</button>
</div>
<div class="date-error" id="sprint-end-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
</div>
</div>
@@ -227,9 +237,6 @@
margin-bottom: 2rem;
}
.sprint-card {
/* Sprint card inherits from .management-card */
}
.sprint-card-header {
display: flex;
@@ -294,6 +301,69 @@
/* Hybrid Date Input Styles */
.hybrid-date-input {
position: relative;
display: flex;
align-items: center;
gap: 0.25rem;
}
.hybrid-date-input.compact {
display: inline-flex;
}
.date-input-native {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 35px); /* Leave space for calendar button */
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
pointer-events: auto;
}
.date-input-formatted {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
position: relative;
z-index: 2;
}
.calendar-picker-btn {
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
z-index: 3;
position: relative;
}
.calendar-picker-btn:hover {
background: #e9ecef;
}
.calendar-picker-btn.compact {
padding: 0.375rem 0.5rem;
font-size: 12px;
}
.hybrid-date-input.compact .date-input-formatted {
padding: 0.375rem;
font-size: 12px;
width: 100px;
flex: none;
}
@media (max-width: 768px) {
.sprint-metrics {
grid-template-columns: repeat(2, 1fr);
@@ -302,6 +372,206 @@
</style>
<script>
// User preferences for date formatting
const USER_DATE_FORMAT = '{{ g.user.preferences.date_format if g.user.preferences else "ISO" }}';
// Date formatting utility function
function formatUserDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
switch (USER_DATE_FORMAT) {
case 'US':
return date.toLocaleDateString('en-US'); // MM/DD/YYYY
case 'EU':
case 'UK':
return date.toLocaleDateString('en-GB'); // DD/MM/YYYY
case 'Readable':
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}); // Mon, Dec 25, 2024
case 'ISO':
default:
return date.toISOString().split('T')[0]; // YYYY-MM-DD
}
}
// Date input formatting function - formats ISO date for user input
function formatDateForInput(isoDateString) {
if (!isoDateString) return '';
const date = new Date(isoDateString);
if (isNaN(date.getTime())) return '';
return formatUserDate(isoDateString);
}
// Date parsing function - converts user-formatted date to ISO format
function parseUserDate(dateString) {
if (!dateString || dateString.trim() === '') return null;
const trimmed = dateString.trim();
let date;
switch (USER_DATE_FORMAT) {
case 'US': // MM/DD/YYYY
const usParts = trimmed.split('/');
if (usParts.length === 3) {
const month = parseInt(usParts[0], 10);
const day = parseInt(usParts[1], 10);
const year = parseInt(usParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'EU':
case 'UK': // DD/MM/YYYY
const euParts = trimmed.split('/');
if (euParts.length === 3) {
const day = parseInt(euParts[0], 10);
const month = parseInt(euParts[1], 10);
const year = parseInt(euParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'Readable': // Mon, Dec 25, 2024
date = new Date(trimmed);
break;
case 'ISO': // YYYY-MM-DD
default:
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
date = new Date(trimmed);
}
break;
}
if (!date || isNaN(date.getTime())) {
return null;
}
return date.toISOString().split('T')[0];
}
// Date validation function
function validateDateInput(inputElement, errorElement) {
const value = inputElement.value.trim();
if (!value) {
errorElement.style.display = 'none';
return true;
}
const parsedDate = parseUserDate(value);
if (!parsedDate) {
let expectedFormat;
switch (USER_DATE_FORMAT) {
case 'US': expectedFormat = 'MM/DD/YYYY'; break;
case 'EU':
case 'UK': expectedFormat = 'DD/MM/YYYY'; break;
case 'Readable': expectedFormat = 'Mon, Dec 25, 2024'; break;
case 'ISO':
default: expectedFormat = 'YYYY-MM-DD'; break;
}
errorElement.textContent = `Invalid date format. Expected: ${expectedFormat}`;
errorElement.style.display = 'block';
return false;
}
errorElement.style.display = 'none';
return true;
}
// Date range validation function
function validateDateRange(startElement, endElement, startErrorElement, endErrorElement) {
const startValid = validateDateInput(startElement, startErrorElement);
const endValid = validateDateInput(endElement, endErrorElement);
if (!startValid || !endValid) {
return false;
}
const startDate = parseUserDate(startElement.value);
const endDate = parseUserDate(endElement.value);
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
if (start >= end) {
endErrorElement.textContent = 'End date must be after start date';
endErrorElement.style.display = 'block';
return false;
}
}
return true;
}
// Hybrid Date Input Functions
function setupHybridDateInput(inputId) {
const formattedInput = document.getElementById(inputId);
const nativeInput = document.getElementById(inputId + '-native');
if (!formattedInput || !nativeInput) return;
// Sync from native input to formatted input
nativeInput.addEventListener('change', function() {
if (this.value) {
formattedInput.value = formatDateForInput(this.value);
// Trigger change event on formatted input
formattedInput.dispatchEvent(new Event('change'));
}
});
// Sync from formatted input to native input
formattedInput.addEventListener('change', function() {
const isoDate = parseUserDate(this.value);
if (isoDate) {
nativeInput.value = isoDate;
} else {
nativeInput.value = '';
}
});
// Clear both inputs when formatted input is cleared
formattedInput.addEventListener('input', function() {
if (this.value === '') {
nativeInput.value = '';
}
});
}
function openCalendarPicker(inputId) {
const nativeInput = document.getElementById(inputId + '-native');
if (nativeInput) {
// Try multiple methods to open the date picker
nativeInput.focus();
// For modern browsers
if (nativeInput.showPicker) {
try {
nativeInput.showPicker();
} catch (e) {
// Fallback to click if showPicker fails
nativeInput.click();
}
} else {
// Fallback for older browsers
nativeInput.click();
}
}
}
// Sprint Management Controller
class SprintManager {
constructor() {
@@ -331,6 +601,27 @@ class SprintManager {
document.getElementById('refresh-sprints').addEventListener('click', () => {
this.loadSprints();
});
// Date validation
document.getElementById('sprint-start-date').addEventListener('blur', () => {
const startInput = document.getElementById('sprint-start-date');
const endInput = document.getElementById('sprint-end-date');
const startError = document.getElementById('sprint-start-date-error');
const endError = document.getElementById('sprint-end-date-error');
validateDateRange(startInput, endInput, startError, endError);
});
document.getElementById('sprint-end-date').addEventListener('blur', () => {
const startInput = document.getElementById('sprint-start-date');
const endInput = document.getElementById('sprint-end-date');
const startError = document.getElementById('sprint-start-date-error');
const endError = document.getElementById('sprint-end-date-error');
validateDateRange(startInput, endInput, startError, endError);
});
// Setup hybrid date inputs
setupHybridDateInput('sprint-start-date');
setupHybridDateInput('sprint-end-date');
// Modal close handlers
document.querySelectorAll('.close').forEach(closeBtn => {
@@ -440,7 +731,7 @@ class SprintManager {
</div>
<div class="sprint-dates">
📅 ${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}
📅 ${formatUserDate(sprint.start_date)} - ${formatUserDate(sprint.end_date)}
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
</div>
@@ -500,8 +791,8 @@ class SprintManager {
document.getElementById('sprint-status').value = sprint.status;
document.getElementById('sprint-project').value = sprint.project_id || '';
document.getElementById('sprint-capacity').value = sprint.capacity_hours || '';
document.getElementById('sprint-start-date').value = sprint.start_date;
document.getElementById('sprint-end-date').value = sprint.end_date;
document.getElementById('sprint-start-date').value = formatDateForInput(sprint.start_date);
document.getElementById('sprint-end-date').value = formatDateForInput(sprint.end_date);
document.getElementById('sprint-goal').value = sprint.goal || '';
document.getElementById('delete-sprint-btn').style.display = 'inline-block';
} else {
@@ -512,8 +803,8 @@ class SprintManager {
// Set default dates (next 2 weeks)
const today = new Date();
const twoWeeksLater = new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000);
document.getElementById('sprint-start-date').value = today.toISOString().split('T')[0];
document.getElementById('sprint-end-date').value = twoWeeksLater.toISOString().split('T')[0];
document.getElementById('sprint-start-date').value = formatDateForInput(today.toISOString().split('T')[0]);
document.getElementById('sprint-end-date').value = formatDateForInput(twoWeeksLater.toISOString().split('T')[0]);
document.getElementById('delete-sprint-btn').style.display = 'none';
}
@@ -522,14 +813,29 @@ class SprintManager {
}
async saveSprint() {
// Validate date inputs before saving
const startInput = document.getElementById('sprint-start-date');
const endInput = document.getElementById('sprint-end-date');
const startError = document.getElementById('sprint-start-date-error');
const endError = document.getElementById('sprint-end-date-error');
if (!validateDateRange(startInput, endInput, startError, endError)) {
if (startError.style.display !== 'none') {
startInput.focus();
} else {
endInput.focus();
}
return;
}
const sprintData = {
name: document.getElementById('sprint-name').value,
description: document.getElementById('sprint-description').value,
status: document.getElementById('sprint-status').value,
project_id: document.getElementById('sprint-project').value || null,
capacity_hours: document.getElementById('sprint-capacity').value || null,
start_date: document.getElementById('sprint-start-date').value,
end_date: document.getElementById('sprint-end-date').value,
start_date: parseUserDate(document.getElementById('sprint-start-date').value),
end_date: parseUserDate(document.getElementById('sprint-end-date').value),
goal: document.getElementById('sprint-goal').value || null
};

View File

@@ -44,10 +44,18 @@
<!-- Date Range Filters -->
<div class="date-range-filters">
<label for="start-date-filter">From:</label>
<input type="date" id="start-date-filter" class="filter-input">
<div class="hybrid-date-input compact">
<input type="date" id="start-date-filter-native" class="date-input-native">
<input type="text" id="start-date-filter" class="date-input-formatted filter-input" placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
<button type="button" class="calendar-picker-btn compact" onclick="openCalendarPicker('start-date-filter')" title="Open calendar">📅</button>
</div>
<label for="end-date-filter">To:</label>
<input type="date" id="end-date-filter" class="filter-input">
<div class="hybrid-date-input compact">
<input type="date" id="end-date-filter-native" class="date-input-native">
<input type="text" id="end-date-filter" class="date-input-formatted filter-input" placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
<button type="button" class="calendar-picker-btn compact" onclick="openCalendarPicker('end-date-filter')" title="Open calendar">📅</button>
</div>
<select id="date-field-filter" class="filter-select">
<option value="created">Created Date</option>
@@ -93,9 +101,9 @@
</div>
</div>
<!-- Kanban Board -->
<div class="kanban-board" id="task-board">
<div class="kanban-column" data-status="NOT_STARTED">
<!-- Task Board -->
<div class="task-board" id="task-board">
<div class="task-column" data-status="NOT_STARTED">
<div class="column-header">
<h3>📝 Not Started</h3>
<span class="task-count">0</span>
@@ -105,7 +113,7 @@
</div>
</div>
<div class="kanban-column" data-status="IN_PROGRESS">
<div class="task-column" data-status="IN_PROGRESS">
<div class="column-header">
<h3>⚡ In Progress</h3>
<span class="task-count">0</span>
@@ -115,7 +123,7 @@
</div>
</div>
<div class="kanban-column" data-status="ON_HOLD">
<div class="task-column" data-status="ON_HOLD">
<div class="column-header">
<h3>⏸️ On Hold</h3>
<span class="task-count">0</span>
@@ -125,7 +133,7 @@
</div>
</div>
<div class="kanban-column" data-status="COMPLETED">
<div class="task-column" data-status="COMPLETED">
<div class="column-header">
<h3>✅ Completed</h3>
<span class="task-count">0</span>
@@ -217,11 +225,12 @@
<div class="form-row">
<div class="form-group">
<label for="task-due-date">Due Date</label>
<input type="date" id="task-due-date">
</div>
<div class="form-group">
<label for="task-estimated-hours">Estimated Hours</label>
<input type="number" id="task-estimated-hours" min="0" step="0.5">
<div class="hybrid-date-input">
<input type="date" id="task-due-date-native" class="date-input-native">
<input type="text" id="task-due-date" class="date-input-formatted" placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('task-due-date')" title="Open calendar">📅</button>
</div>
<div class="date-error" id="task-due-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
</div>
</div>
@@ -293,14 +302,77 @@
white-space: nowrap;
}
.kanban-board {
/* Hybrid Date Input Styles */
.hybrid-date-input {
position: relative;
display: flex;
align-items: center;
gap: 0.25rem;
}
.hybrid-date-input.compact {
display: inline-flex;
}
.date-input-native {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 35px); /* Leave space for calendar button */
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
pointer-events: auto;
}
.date-input-formatted {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
position: relative;
z-index: 2;
}
.calendar-picker-btn {
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
z-index: 3;
position: relative;
}
.calendar-picker-btn:hover {
background: #e9ecef;
}
.calendar-picker-btn.compact {
padding: 0.375rem 0.5rem;
font-size: 12px;
}
.hybrid-date-input.compact .date-input-formatted {
padding: 0.375rem;
font-size: 12px;
width: 100px;
flex: none;
}
.task-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.kanban-column {
.task-column {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
@@ -431,7 +503,7 @@
}
@media (max-width: 768px) {
.kanban-board {
.task-board {
grid-template-columns: 1fr;
}
}
@@ -441,6 +513,179 @@
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script>
// User preferences for date formatting
const USER_DATE_FORMAT = '{{ g.user.preferences.date_format if g.user.preferences else "ISO" }}';
// Date formatting utility function
function formatUserDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
switch (USER_DATE_FORMAT) {
case 'US':
return date.toLocaleDateString('en-US'); // MM/DD/YYYY
case 'EU':
case 'UK':
return date.toLocaleDateString('en-GB'); // DD/MM/YYYY
case 'Readable':
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}); // Mon, Dec 25, 2024
case 'ISO':
default:
return date.toISOString().split('T')[0]; // YYYY-MM-DD
}
}
// Date input formatting function - formats ISO date for user input
function formatDateForInput(isoDateString) {
if (!isoDateString) return '';
const date = new Date(isoDateString);
if (isNaN(date.getTime())) return '';
return formatUserDate(isoDateString);
}
// Date parsing function - converts user-formatted date to ISO format
function parseUserDate(dateString) {
if (!dateString || dateString.trim() === '') return null;
const trimmed = dateString.trim();
let date;
switch (USER_DATE_FORMAT) {
case 'US': // MM/DD/YYYY
const usParts = trimmed.split('/');
if (usParts.length === 3) {
const month = parseInt(usParts[0], 10);
const day = parseInt(usParts[1], 10);
const year = parseInt(usParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'EU':
case 'UK': // DD/MM/YYYY
const euParts = trimmed.split('/');
if (euParts.length === 3) {
const day = parseInt(euParts[0], 10);
const month = parseInt(euParts[1], 10);
const year = parseInt(euParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'Readable': // Mon, Dec 25, 2024
date = new Date(trimmed);
break;
case 'ISO': // YYYY-MM-DD
default:
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
date = new Date(trimmed);
}
break;
}
if (!date || isNaN(date.getTime())) {
return null;
}
return date.toISOString().split('T')[0];
}
// Date validation function
function validateDateInput(inputElement, errorElement) {
const value = inputElement.value.trim();
if (!value) {
errorElement.style.display = 'none';
return true;
}
const parsedDate = parseUserDate(value);
if (!parsedDate) {
let expectedFormat;
switch (USER_DATE_FORMAT) {
case 'US': expectedFormat = 'MM/DD/YYYY'; break;
case 'EU':
case 'UK': expectedFormat = 'DD/MM/YYYY'; break;
case 'Readable': expectedFormat = 'Mon, Dec 25, 2024'; break;
case 'ISO':
default: expectedFormat = 'YYYY-MM-DD'; break;
}
errorElement.textContent = `Invalid date format. Expected: ${expectedFormat}`;
errorElement.style.display = 'block';
return false;
}
errorElement.style.display = 'none';
return true;
}
// Hybrid Date Input Functions
function setupHybridDateInput(inputId) {
const formattedInput = document.getElementById(inputId);
const nativeInput = document.getElementById(inputId + '-native');
if (!formattedInput || !nativeInput) return;
// Sync from native input to formatted input
nativeInput.addEventListener('change', function() {
if (this.value) {
formattedInput.value = formatDateForInput(this.value);
// Trigger change event on formatted input
formattedInput.dispatchEvent(new Event('change'));
}
});
// Sync from formatted input to native input
formattedInput.addEventListener('change', function() {
const isoDate = parseUserDate(this.value);
if (isoDate) {
nativeInput.value = isoDate;
} else {
nativeInput.value = '';
}
});
// Clear both inputs when formatted input is cleared
formattedInput.addEventListener('input', function() {
if (this.value === '') {
nativeInput.value = '';
}
});
}
function openCalendarPicker(inputId) {
const nativeInput = document.getElementById(inputId + '-native');
if (nativeInput) {
nativeInput.focus();
// Try showPicker() first for modern browsers
if (nativeInput.showPicker) {
try {
nativeInput.showPicker();
} catch (e) {
// Fallback to click if showPicker fails
nativeInput.click();
}
} else {
// Fallback for older browsers
nativeInput.click();
}
}
}
// Task Management Controller
class UnifiedTaskManager {
constructor() {
@@ -457,6 +702,7 @@ class UnifiedTaskManager {
};
this.currentTask = null;
this.sortableInstances = [];
this.currentUserId = {{ g.user.id|tojson }};
}
async init() {
@@ -492,12 +738,12 @@ class UnifiedTaskManager {
// Date range filters
document.getElementById('start-date-filter').addEventListener('change', () => {
this.filters.startDate = document.getElementById('start-date-filter').value;
this.filters.startDate = parseUserDate(document.getElementById('start-date-filter').value) || '';
this.applyFilters();
});
document.getElementById('end-date-filter').addEventListener('change', () => {
this.filters.endDate = document.getElementById('end-date-filter').value;
this.filters.endDate = parseUserDate(document.getElementById('end-date-filter').value) || '';
this.applyFilters();
});
@@ -520,6 +766,18 @@ class UnifiedTaskManager {
document.getElementById('refresh-tasks').addEventListener('click', () => {
this.loadTasks();
});
// Date validation
document.getElementById('task-due-date').addEventListener('blur', () => {
const input = document.getElementById('task-due-date');
const error = document.getElementById('task-due-date-error');
validateDateInput(input, error);
});
// Setup hybrid date inputs
setupHybridDateInput('task-due-date');
setupHybridDateInput('start-date-filter');
setupHybridDateInput('end-date-filter');
}
setupSortable() {
@@ -638,7 +896,7 @@ class UnifiedTaskManager {
getFilteredTasks() {
return this.tasks.filter(task => {
// View filter
if (this.currentView === 'personal' && task.assigned_to_id !== {{ g.user.id }}) {
if (this.currentView === 'personal' && task.assigned_to_id !== this.currentUserId) {
return false;
}
if (this.currentView === 'team' && !task.is_team_task) {
@@ -732,7 +990,7 @@ class UnifiedTaskManager {
${task.project_name ? `<div class="task-project">${task.project_code} - ${task.project_name}</div>` : ''}
<div class="task-meta">
<span class="task-assignee">${task.assigned_to_name || 'Unassigned'}</span>
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${dueDate.toLocaleDateString()}</span>` : ''}
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
</div>
`;
@@ -801,7 +1059,7 @@ class UnifiedTaskManager {
document.getElementById('task-priority').value = task.priority;
document.getElementById('task-project').value = task.project_id || '';
document.getElementById('task-assignee').value = task.assigned_to_id || '';
document.getElementById('task-due-date').value = task.due_date || '';
document.getElementById('task-due-date').value = formatDateForInput(task.due_date) || '';
document.getElementById('task-estimated-hours').value = task.estimated_hours || '';
document.getElementById('task-status').value = task.status;
document.getElementById('delete-task-btn').style.display = 'inline-block';
@@ -816,13 +1074,22 @@ class UnifiedTaskManager {
}
async saveTask() {
// Validate date input before saving
const dueDateInput = document.getElementById('task-due-date');
const dueDateError = document.getElementById('task-due-date-error');
if (!validateDateInput(dueDateInput, dueDateError)) {
dueDateInput.focus();
return;
}
const taskData = {
name: document.getElementById('task-name').value,
description: document.getElementById('task-description').value,
priority: document.getElementById('task-priority').value,
project_id: document.getElementById('task-project').value || null,
assigned_to_id: document.getElementById('task-assignee').value || null,
due_date: document.getElementById('task-due-date').value || null,
due_date: parseUserDate(document.getElementById('task-due-date').value) || null,
estimated_hours: document.getElementById('task-estimated-hours').value || null,
status: document.getElementById('task-status').value
};