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 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 (
|
||||
format_duration, prepare_export_data, prepare_team_hours_export_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'):
|
||||
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
|
||||
task = Task(
|
||||
task_number=task_number,
|
||||
name=name,
|
||||
description=data.get('description', ''),
|
||||
status=TaskStatus[data.get('status', 'NOT_STARTED')],
|
||||
@@ -3924,6 +3928,7 @@ def get_unified_tasks():
|
||||
|
||||
task_data = {
|
||||
'id': task.id,
|
||||
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'), # Fallback for existing tasks
|
||||
'name': task.name,
|
||||
'description': task.description,
|
||||
'status': task.status.name,
|
||||
@@ -4006,6 +4011,194 @@ def update_task_status(task_id):
|
||||
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
|
||||
@app.route('/api/sprints')
|
||||
@role_required(Role.TEAM_MEMBER)
|
||||
@@ -5046,6 +5239,118 @@ def get_current_timer_status():
|
||||
logger.error(f"Error getting timer status: {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__':
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
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
|
||||
class Task(db.Model):
|
||||
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)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -488,6 +489,68 @@ class Task(db.Model):
|
||||
"""Check if a user can access this task"""
|
||||
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
|
||||
class SubTask(db.Model):
|
||||
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