Add smart search for Task Management.
This commit is contained in:
307
app.py
307
app.py
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
||||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate
|
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate
|
||||||
from data_formatting import (
|
from data_formatting import (
|
||||||
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
||||||
format_table_data, format_graph_data, format_team_data, format_burndown_data
|
format_table_data, format_graph_data, format_team_data, format_burndown_data
|
||||||
@@ -3763,8 +3763,12 @@ def create_task():
|
|||||||
if data.get('due_date'):
|
if data.get('due_date'):
|
||||||
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Generate task number
|
||||||
|
task_number = Task.generate_task_number(g.user.company_id)
|
||||||
|
|
||||||
# Create task
|
# Create task
|
||||||
task = Task(
|
task = Task(
|
||||||
|
task_number=task_number,
|
||||||
name=name,
|
name=name,
|
||||||
description=data.get('description', ''),
|
description=data.get('description', ''),
|
||||||
status=TaskStatus[data.get('status', 'NOT_STARTED')],
|
status=TaskStatus[data.get('status', 'NOT_STARTED')],
|
||||||
@@ -3924,6 +3928,7 @@ def get_unified_tasks():
|
|||||||
|
|
||||||
task_data = {
|
task_data = {
|
||||||
'id': task.id,
|
'id': task.id,
|
||||||
|
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'), # Fallback for existing tasks
|
||||||
'name': task.name,
|
'name': task.name,
|
||||||
'description': task.description,
|
'description': task.description,
|
||||||
'status': task.status.name,
|
'status': task.status.name,
|
||||||
@@ -4006,6 +4011,194 @@ def update_task_status(task_id):
|
|||||||
return jsonify({'success': False, 'message': str(e)})
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Task Dependencies APIs
|
||||||
|
@app.route('/api/tasks/<int:task_id>/dependencies')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_task_dependencies(task_id):
|
||||||
|
"""Get dependencies for a specific task"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership
|
||||||
|
task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Get blocked by dependencies (tasks that block this one)
|
||||||
|
blocked_by_query = db.session.query(Task).join(
|
||||||
|
TaskDependency, Task.id == TaskDependency.blocking_task_id
|
||||||
|
).filter(TaskDependency.blocked_task_id == task_id)
|
||||||
|
|
||||||
|
# Get blocks dependencies (tasks that this one blocks)
|
||||||
|
blocks_query = db.session.query(Task).join(
|
||||||
|
TaskDependency, Task.id == TaskDependency.blocked_task_id
|
||||||
|
).filter(TaskDependency.blocking_task_id == task_id)
|
||||||
|
|
||||||
|
blocked_by_tasks = blocked_by_query.all()
|
||||||
|
blocks_tasks = blocks_query.all()
|
||||||
|
|
||||||
|
def task_to_dict(t):
|
||||||
|
return {
|
||||||
|
'id': t.id,
|
||||||
|
'name': t.name,
|
||||||
|
'task_number': t.task_number
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'dependencies': {
|
||||||
|
'blocked_by': [task_to_dict(t) for t in blocked_by_tasks],
|
||||||
|
'blocks': [task_to_dict(t) for t in blocks_tasks]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task dependencies: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<int:task_id>/dependencies', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def add_task_dependency(task_id):
|
||||||
|
"""Add a dependency for a task"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
task_number = data.get('task_number')
|
||||||
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
||||||
|
|
||||||
|
if not task_number or not dependency_type:
|
||||||
|
return jsonify({'success': False, 'message': 'Task number and type are required'})
|
||||||
|
|
||||||
|
# Get the main task
|
||||||
|
task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Find the dependency task by task number
|
||||||
|
dependency_task = Task.query.filter_by(
|
||||||
|
task_number=task_number,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dependency_task:
|
||||||
|
return jsonify({'success': False, 'message': f'Task {task_number} not found'})
|
||||||
|
|
||||||
|
# Prevent self-dependency
|
||||||
|
if dependency_task.id == task_id:
|
||||||
|
return jsonify({'success': False, 'message': 'A task cannot depend on itself'})
|
||||||
|
|
||||||
|
# Create the dependency based on type
|
||||||
|
if dependency_type == 'blocked_by':
|
||||||
|
# Current task is blocked by the dependency task
|
||||||
|
blocked_task_id = task_id
|
||||||
|
blocking_task_id = dependency_task.id
|
||||||
|
elif dependency_type == 'blocks':
|
||||||
|
# Current task blocks the dependency task
|
||||||
|
blocked_task_id = dependency_task.id
|
||||||
|
blocking_task_id = task_id
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid dependency type'})
|
||||||
|
|
||||||
|
# Check if dependency already exists
|
||||||
|
existing_dep = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=blocked_task_id,
|
||||||
|
blocking_task_id=blocking_task_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_dep:
|
||||||
|
return jsonify({'success': False, 'message': 'This dependency already exists'})
|
||||||
|
|
||||||
|
# Check for circular dependencies
|
||||||
|
def would_create_cycle(blocked_id, blocking_id):
|
||||||
|
# Use a simple DFS to check if adding this dependency would create a cycle
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def dfs(current_blocked_id):
|
||||||
|
if current_blocked_id in visited:
|
||||||
|
return False
|
||||||
|
visited.add(current_blocked_id)
|
||||||
|
|
||||||
|
# If we reach the original blocking task, we have a cycle
|
||||||
|
if current_blocked_id == blocking_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check all tasks that block the current task
|
||||||
|
dependencies = TaskDependency.query.filter_by(blocked_task_id=current_blocked_id).all()
|
||||||
|
for dep in dependencies:
|
||||||
|
if dfs(dep.blocking_task_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return dfs(blocked_id)
|
||||||
|
|
||||||
|
if would_create_cycle(blocked_task_id, blocking_task_id):
|
||||||
|
return jsonify({'success': False, 'message': 'This dependency would create a circular dependency'})
|
||||||
|
|
||||||
|
# Create the new dependency
|
||||||
|
new_dependency = TaskDependency(
|
||||||
|
blocked_task_id=blocked_task_id,
|
||||||
|
blocking_task_id=blocking_task_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(new_dependency)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Dependency added successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error adding task dependency: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<int:task_id>/dependencies/<int:dependency_task_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def remove_task_dependency(task_id, dependency_task_id):
|
||||||
|
"""Remove a dependency for a task"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
||||||
|
|
||||||
|
if not dependency_type:
|
||||||
|
return jsonify({'success': False, 'message': 'Dependency type is required'})
|
||||||
|
|
||||||
|
# Get the main task
|
||||||
|
task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Determine which dependency to remove based on type
|
||||||
|
if dependency_type == 'blocked_by':
|
||||||
|
# Remove dependency where current task is blocked by dependency_task_id
|
||||||
|
dependency = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=task_id,
|
||||||
|
blocking_task_id=dependency_task_id
|
||||||
|
).first()
|
||||||
|
elif dependency_type == 'blocks':
|
||||||
|
# Remove dependency where current task blocks dependency_task_id
|
||||||
|
dependency = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=dependency_task_id,
|
||||||
|
blocking_task_id=task_id
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid dependency type'})
|
||||||
|
|
||||||
|
if not dependency:
|
||||||
|
return jsonify({'success': False, 'message': 'Dependency not found'})
|
||||||
|
|
||||||
|
db.session.delete(dependency)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Dependency removed successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error removing task dependency: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
# Sprint Management APIs
|
# Sprint Management APIs
|
||||||
@app.route('/api/sprints')
|
@app.route('/api/sprints')
|
||||||
@role_required(Role.TEAM_MEMBER)
|
@role_required(Role.TEAM_MEMBER)
|
||||||
@@ -5046,6 +5239,118 @@ def get_current_timer_status():
|
|||||||
logger.error(f"Error getting timer status: {e}")
|
logger.error(f"Error getting timer status: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)})
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
# Smart Search API Endpoints
|
||||||
|
@app.route('/api/search/users')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def search_users():
|
||||||
|
"""Search for users for smart search auto-completion"""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({'success': True, 'users': []})
|
||||||
|
|
||||||
|
# Search users in the same company
|
||||||
|
users = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
User.username.ilike(f'%{query}%')
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
user_list = [
|
||||||
|
{
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'full_name': f"{user.first_name} {user.last_name}" if user.first_name and user.last_name else user.username
|
||||||
|
}
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'users': user_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search_users: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/search/projects')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def search_projects():
|
||||||
|
"""Search for projects for smart search auto-completion"""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({'success': True, 'projects': []})
|
||||||
|
|
||||||
|
# Search projects the user has access to
|
||||||
|
projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
db.or_(
|
||||||
|
Project.code.ilike(f'%{query}%'),
|
||||||
|
Project.name.ilike(f'%{query}%')
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
# Filter projects user has access to
|
||||||
|
accessible_projects = [
|
||||||
|
project for project in projects
|
||||||
|
if project.is_user_allowed(g.user)
|
||||||
|
]
|
||||||
|
|
||||||
|
project_list = [
|
||||||
|
{
|
||||||
|
'id': project.id,
|
||||||
|
'code': project.code,
|
||||||
|
'name': project.name
|
||||||
|
}
|
||||||
|
for project in accessible_projects
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'projects': project_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search_projects: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/search/sprints')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def search_sprints():
|
||||||
|
"""Search for sprints for smart search auto-completion"""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({'success': True, 'sprints': []})
|
||||||
|
|
||||||
|
# Search sprints in the same company
|
||||||
|
sprints = Sprint.query.filter(
|
||||||
|
Sprint.company_id == g.user.company_id,
|
||||||
|
Sprint.name.ilike(f'%{query}%')
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
# Filter sprints user has access to
|
||||||
|
accessible_sprints = [
|
||||||
|
sprint for sprint in sprints
|
||||||
|
if sprint.can_user_access(g.user)
|
||||||
|
]
|
||||||
|
|
||||||
|
sprint_list = [
|
||||||
|
{
|
||||||
|
'id': sprint.id,
|
||||||
|
'name': sprint.name,
|
||||||
|
'status': sprint.status.value
|
||||||
|
}
|
||||||
|
for sprint in accessible_sprints
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'sprints': sprint_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search_sprints: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
app.run(debug=True, host='0.0.0.0', port=port)
|
app.run(debug=True, host='0.0.0.0', port=port)
|
||||||
63
models.py
63
models.py
@@ -433,6 +433,7 @@ class TaskPriority(enum.Enum):
|
|||||||
# Task model for project breakdown
|
# Task model for project breakdown
|
||||||
class Task(db.Model):
|
class Task(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002"
|
||||||
name = db.Column(db.String(200), nullable=False)
|
name = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
@@ -488,6 +489,68 @@ class Task(db.Model):
|
|||||||
"""Check if a user can access this task"""
|
"""Check if a user can access this task"""
|
||||||
return self.project.is_user_allowed(user)
|
return self.project.is_user_allowed(user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_task_number(cls, company_id):
|
||||||
|
"""Generate next task number for the company"""
|
||||||
|
# Get the highest task number for this company
|
||||||
|
last_task = cls.query.join(Project).filter(
|
||||||
|
Project.company_id == company_id,
|
||||||
|
cls.task_number.like('TSK-%')
|
||||||
|
).order_by(cls.task_number.desc()).first()
|
||||||
|
|
||||||
|
if last_task and last_task.task_number:
|
||||||
|
try:
|
||||||
|
# Extract number from TSK-XXX format
|
||||||
|
last_num = int(last_task.task_number.split('-')[1])
|
||||||
|
return f"TSK-{last_num + 1:03d}"
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "TSK-001"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked_by_tasks(self):
|
||||||
|
"""Get tasks that are blocking this task"""
|
||||||
|
return [dep.blocking_task for dep in self.blocked_by_dependencies]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocking_tasks(self):
|
||||||
|
"""Get tasks that this task is blocking"""
|
||||||
|
return [dep.blocked_task for dep in self.blocking_dependencies]
|
||||||
|
|
||||||
|
# Task Dependencies model for tracking blocking relationships
|
||||||
|
class TaskDependency(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# The task that is blocked (cannot start until blocking task is done)
|
||||||
|
blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# The task that is blocking (must be completed before blocked task can start)
|
||||||
|
blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# Dependency type (for future extension)
|
||||||
|
dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc.
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id],
|
||||||
|
backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan'))
|
||||||
|
blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id],
|
||||||
|
backref=db.backref('blocking_dependencies', cascade='all, delete-orphan'))
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
# Ensure a task doesn't block itself and prevent duplicate dependencies
|
||||||
|
__table_args__ = (
|
||||||
|
db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'),
|
||||||
|
db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TaskDependency {self.blocking_task_id} blocks {self.blocked_task_id}>'
|
||||||
|
|
||||||
# SubTask model for task breakdown
|
# SubTask model for task breakdown
|
||||||
class SubTask(db.Model):
|
class SubTask(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|||||||
467
templates/task_modal.html
Normal file
467
templates/task_modal.html
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<!-- Task Detail Modal -->
|
||||||
|
<div id="task-modal" class="modal task-modal" style="display: none;">
|
||||||
|
<div class="modal-content task-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Task Details</h2>
|
||||||
|
<span class="close" onclick="closeTaskModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="task-form">
|
||||||
|
<input type="hidden" id="task-id">
|
||||||
|
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>📝 Basic Information</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-name">Task Name *</label>
|
||||||
|
<input type="text" id="task-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-priority">Priority</label>
|
||||||
|
<select id="task-priority">
|
||||||
|
<option value="LOW">Low</option>
|
||||||
|
<option value="MEDIUM">Medium</option>
|
||||||
|
<option value="HIGH">High</option>
|
||||||
|
<option value="URGENT">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-description">Description</label>
|
||||||
|
<textarea id="task-description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-status">Status</label>
|
||||||
|
<select id="task-status">
|
||||||
|
<option value="NOT_STARTED">Not Started</option>
|
||||||
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
|
<option value="ON_HOLD">On Hold</option>
|
||||||
|
<option value="COMPLETED">Completed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment & Planning -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>👥 Assignment & Planning</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-project">Project</label>
|
||||||
|
<select id="task-project">
|
||||||
|
<option value="">Select Project</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-assignee">Assigned To</label>
|
||||||
|
<select id="task-assignee">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{% for user in team_members %}
|
||||||
|
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-sprint">Sprint</label>
|
||||||
|
<select id="task-sprint">
|
||||||
|
<option value="">No Sprint</option>
|
||||||
|
<!-- Sprint options will be populated dynamically -->
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-due-date">Due Date</label>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Dependencies -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>🔗 Dependencies</h3>
|
||||||
|
<div class="dependencies-grid">
|
||||||
|
<!-- Blocked By -->
|
||||||
|
<div class="dependency-column">
|
||||||
|
<h4>🚫 Blocked By</h4>
|
||||||
|
<p class="dependency-help">Tasks that must be completed before this task can start</p>
|
||||||
|
<div id="blocked-by-container" class="dependency-list">
|
||||||
|
<!-- Blocked by tasks will be populated here -->
|
||||||
|
</div>
|
||||||
|
<div class="add-dependency-form">
|
||||||
|
<input type="text" id="blocked-by-input" placeholder="TSK-001" class="dependency-input">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="addBlockedBy()">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocks -->
|
||||||
|
<div class="dependency-column">
|
||||||
|
<h4>🔒 Blocks</h4>
|
||||||
|
<p class="dependency-help">Tasks that cannot start until this task is completed</p>
|
||||||
|
<div id="blocks-container" class="dependency-list">
|
||||||
|
<!-- Blocks tasks will be populated here -->
|
||||||
|
</div>
|
||||||
|
<div class="add-dependency-form">
|
||||||
|
<input type="text" id="blocks-input" placeholder="TSK-002" class="dependency-input">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="addBlocks()">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtasks -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>📋 Subtasks</h3>
|
||||||
|
<div id="subtasks-container">
|
||||||
|
<!-- Subtasks will be populated here -->
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="addSubtask()">+ Add Subtask</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveTask()">Save Task</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="deleteTask()" id="delete-task-btn" style="display: none;">Delete Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Modal JavaScript Functions -->
|
||||||
|
<script>
|
||||||
|
// Hybrid Date Input Functions for task modal
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize hybrid date inputs when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Setup hybrid date inputs for task modal
|
||||||
|
setupHybridDateInput('task-due-date');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Task Modal Styles -->
|
||||||
|
<style>
|
||||||
|
/* Task Modal Specific Styles */
|
||||||
|
.task-modal .modal-content {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 1000px;
|
||||||
|
max-height: 95vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-content .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dependencies Grid */
|
||||||
|
.dependencies-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-column {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-column h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-help {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-list {
|
||||||
|
min-height: 60px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-task-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-task-number {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-task-title {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-remove-btn {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-remove-btn:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dependency-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-input::placeholder {
|
||||||
|
color: #adb5bd;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtasks Section */
|
||||||
|
.subtask-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-item input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-item button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hybrid Date Input Styles */
|
||||||
|
.hybrid-date-input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-native {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: calc(100% - 35px);
|
||||||
|
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: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-modal .modal-content {
|
||||||
|
width: 98%;
|
||||||
|
margin: 1% auto;
|
||||||
|
max-height: 98vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependencies-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-task-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.task-modal-content .modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-task-number {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user