Add smart search for Task Management.

This commit is contained in:
2025-07-06 08:12:58 +02:00
parent 684d5041d8
commit 8f63817194
4 changed files with 1761 additions and 359 deletions

307
app.py
View File

@@ -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)

View File

@@ -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
View 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()">&times;</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