Add Task Archive feature.
This commit is contained in:
88
app.py
88
app.py
@@ -69,6 +69,15 @@ def run_migrations():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
init_system_settings()
|
init_system_settings()
|
||||||
|
|
||||||
|
# Run PostgreSQL-specific migrations
|
||||||
|
try:
|
||||||
|
from migrate_db import migrate_postgresql_schema
|
||||||
|
migrate_postgresql_schema()
|
||||||
|
except ImportError:
|
||||||
|
print("PostgreSQL migration function not available")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: PostgreSQL migration failed: {e}")
|
||||||
print("PostgreSQL setup completed successfully!")
|
print("PostgreSQL setup completed successfully!")
|
||||||
else:
|
else:
|
||||||
print("Using SQLite - running SQLite migrations...")
|
print("Using SQLite - running SQLite migrations...")
|
||||||
@@ -3996,6 +4005,13 @@ def update_task_status(task_id):
|
|||||||
# Clear completion date if moving away from completed
|
# Clear completion date if moving away from completed
|
||||||
task.completed_date = None
|
task.completed_date = None
|
||||||
|
|
||||||
|
# Set archived date if status is ARCHIVED
|
||||||
|
if task_status == TaskStatus.ARCHIVED:
|
||||||
|
task.archived_date = datetime.now().date()
|
||||||
|
elif old_status == TaskStatus.ARCHIVED:
|
||||||
|
# Clear archived date if moving away from archived
|
||||||
|
task.archived_date = None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -4199,6 +4215,78 @@ def remove_task_dependency(task_id, dependency_task_id):
|
|||||||
return jsonify({'success': False, 'message': str(e)})
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Task Archive/Restore APIs
|
||||||
|
@app.route('/api/tasks/<int:task_id>/archive', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def archive_task(task_id):
|
||||||
|
"""Archive a completed task"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Only allow archiving completed tasks
|
||||||
|
if task.status != TaskStatus.COMPLETED:
|
||||||
|
return jsonify({'success': False, 'message': 'Only completed tasks can be archived'})
|
||||||
|
|
||||||
|
# Archive the task
|
||||||
|
task.status = TaskStatus.ARCHIVED
|
||||||
|
task.archived_date = datetime.now().date()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task archived successfully',
|
||||||
|
'archived_date': task.archived_date.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error archiving task: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<int:task_id>/restore', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def restore_task(task_id):
|
||||||
|
"""Restore an archived task to completed status"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Only allow restoring archived tasks
|
||||||
|
if task.status != TaskStatus.ARCHIVED:
|
||||||
|
return jsonify({'success': False, 'message': 'Only archived tasks can be restored'})
|
||||||
|
|
||||||
|
# Restore the task to completed status
|
||||||
|
task.status = TaskStatus.COMPLETED
|
||||||
|
task.archived_date = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task restored successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error restoring task: {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)
|
||||||
|
|||||||
203
migrate_db.py
203
migrate_db.py
@@ -74,6 +74,10 @@ def run_all_migrations(db_path=None):
|
|||||||
migrate_task_system(db_path)
|
migrate_task_system(db_path)
|
||||||
migrate_system_events(db_path)
|
migrate_system_events(db_path)
|
||||||
migrate_dashboard_system(db_path)
|
migrate_dashboard_system(db_path)
|
||||||
|
|
||||||
|
# Run PostgreSQL-specific migrations if applicable
|
||||||
|
if FLASK_AVAILABLE:
|
||||||
|
migrate_postgresql_schema()
|
||||||
|
|
||||||
if FLASK_AVAILABLE:
|
if FLASK_AVAILABLE:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -604,24 +608,61 @@ def migrate_task_system(db_path):
|
|||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE task (
|
CREATE TABLE task (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_number VARCHAR(20) NOT NULL UNIQUE,
|
||||||
name VARCHAR(200) NOT NULL,
|
name VARCHAR(200) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
status VARCHAR(50) DEFAULT 'Not Started',
|
status VARCHAR(50) DEFAULT 'Not Started',
|
||||||
priority VARCHAR(50) DEFAULT 'Medium',
|
priority VARCHAR(50) DEFAULT 'Medium',
|
||||||
estimated_hours FLOAT,
|
estimated_hours FLOAT,
|
||||||
project_id INTEGER NOT NULL,
|
project_id INTEGER NOT NULL,
|
||||||
|
sprint_id INTEGER,
|
||||||
assigned_to_id INTEGER,
|
assigned_to_id INTEGER,
|
||||||
start_date DATE,
|
start_date DATE,
|
||||||
due_date DATE,
|
due_date DATE,
|
||||||
completed_date DATE,
|
completed_date DATE,
|
||||||
|
archived_date DATE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by_id INTEGER NOT NULL,
|
created_by_id INTEGER NOT NULL,
|
||||||
FOREIGN KEY (project_id) REFERENCES project (id),
|
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||||
|
FOREIGN KEY (sprint_id) REFERENCES sprint (id),
|
||||||
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
||||||
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
else:
|
||||||
|
# Add missing columns to existing task table
|
||||||
|
cursor.execute("PRAGMA table_info(task)")
|
||||||
|
task_columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
|
||||||
|
task_migrations = [
|
||||||
|
('task_number', "ALTER TABLE task ADD COLUMN task_number VARCHAR(20)"),
|
||||||
|
('sprint_id', "ALTER TABLE task ADD COLUMN sprint_id INTEGER"),
|
||||||
|
('archived_date', "ALTER TABLE task ADD COLUMN archived_date DATE")
|
||||||
|
]
|
||||||
|
|
||||||
|
for column_name, sql_command in task_migrations:
|
||||||
|
if column_name not in task_columns:
|
||||||
|
print(f"Adding {column_name} column to task table...")
|
||||||
|
cursor.execute(sql_command)
|
||||||
|
|
||||||
|
# Add unique constraint for task_number if it was just added
|
||||||
|
if 'task_number' not in task_columns:
|
||||||
|
print("Adding unique constraint for task_number...")
|
||||||
|
# For SQLite, we need to recreate the table to add unique constraint
|
||||||
|
cursor.execute("CREATE UNIQUE INDEX idx_task_number ON task(task_number)")
|
||||||
|
|
||||||
|
# Generate task numbers for existing tasks that don't have them
|
||||||
|
print("Generating task numbers for existing tasks...")
|
||||||
|
cursor.execute("SELECT id FROM task WHERE task_number IS NULL ORDER BY id")
|
||||||
|
tasks_without_numbers = cursor.fetchall()
|
||||||
|
|
||||||
|
for i, (task_id,) in enumerate(tasks_without_numbers, 1):
|
||||||
|
task_number = f"TSK-{i:03d}"
|
||||||
|
cursor.execute("UPDATE task SET task_number = ? WHERE id = ?", (task_number, task_id))
|
||||||
|
|
||||||
|
if tasks_without_numbers:
|
||||||
|
print(f"Generated {len(tasks_without_numbers)} task numbers")
|
||||||
|
|
||||||
# Check if sub_task table exists
|
# Check if sub_task table exists
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sub_task'")
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sub_task'")
|
||||||
@@ -649,6 +690,49 @@ def migrate_task_system(db_path):
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Check if task_dependency table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_dependency'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Creating task_dependency table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE task_dependency (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
blocked_task_id INTEGER NOT NULL,
|
||||||
|
blocking_task_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (blocked_task_id) REFERENCES task (id),
|
||||||
|
FOREIGN KEY (blocking_task_id) REFERENCES task (id),
|
||||||
|
UNIQUE(blocked_task_id, blocking_task_id),
|
||||||
|
CHECK (blocked_task_id != blocking_task_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Check if sprint table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sprint'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Creating sprint table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE sprint (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50) DEFAULT 'PLANNING',
|
||||||
|
goal TEXT,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
capacity_hours INTEGER,
|
||||||
|
project_id INTEGER,
|
||||||
|
company_id INTEGER NOT NULL,
|
||||||
|
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 (company_id) REFERENCES company (id),
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
UNIQUE(company_id, name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Add category_id to project table if it doesn't exist
|
# Add category_id to project table if it doesn't exist
|
||||||
cursor.execute("PRAGMA table_info(project)")
|
cursor.execute("PRAGMA table_info(project)")
|
||||||
project_columns = [column[1] for column in cursor.fetchall()]
|
project_columns = [column[1] for column in cursor.fetchall()]
|
||||||
@@ -884,6 +968,120 @@ def create_all_tables(cursor):
|
|||||||
print("All tables created")
|
print("All tables created")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_postgresql_schema():
|
||||||
|
"""Migrate PostgreSQL schema for archive functionality."""
|
||||||
|
if not FLASK_AVAILABLE:
|
||||||
|
print("Skipping PostgreSQL migration - Flask not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Check if we're using PostgreSQL
|
||||||
|
database_url = app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
|
if not ('postgresql://' in database_url or 'postgres://' in database_url):
|
||||||
|
print("Not using PostgreSQL - skipping PostgreSQL migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Running PostgreSQL schema migrations...")
|
||||||
|
|
||||||
|
# Check if archived_date column exists
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'task' AND column_name = 'archived_date'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not result.fetchone():
|
||||||
|
print("Adding archived_date column to task table...")
|
||||||
|
db.session.execute(text("ALTER TABLE task ADD COLUMN archived_date DATE"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Check if ARCHIVED status exists in enum
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT enumlabel
|
||||||
|
FROM pg_enum
|
||||||
|
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'taskstatus')
|
||||||
|
AND enumlabel = 'ARCHIVED'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not result.fetchone():
|
||||||
|
print("Adding ARCHIVED status to TaskStatus enum...")
|
||||||
|
db.session.execute(text("ALTER TYPE taskstatus ADD VALUE 'ARCHIVED'"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Check if task_number column exists
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'task' AND column_name = 'task_number'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not result.fetchone():
|
||||||
|
print("Adding task_number column to task table...")
|
||||||
|
db.session.execute(text("ALTER TABLE task ADD COLUMN task_number VARCHAR(20) UNIQUE"))
|
||||||
|
|
||||||
|
# Generate task numbers for existing tasks
|
||||||
|
print("Generating task numbers for existing tasks...")
|
||||||
|
result = db.session.execute(text("SELECT id FROM task WHERE task_number IS NULL ORDER BY id"))
|
||||||
|
tasks_without_numbers = result.fetchall()
|
||||||
|
|
||||||
|
for i, (task_id,) in enumerate(tasks_without_numbers, 1):
|
||||||
|
task_number = f"TSK-{i:03d}"
|
||||||
|
db.session.execute(text("UPDATE task SET task_number = :task_number WHERE id = :task_id"),
|
||||||
|
{"task_number": task_number, "task_id": task_id})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
if tasks_without_numbers:
|
||||||
|
print(f"Generated {len(tasks_without_numbers)} task numbers")
|
||||||
|
|
||||||
|
# Check if sprint_id column exists
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'task' AND column_name = 'sprint_id'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not result.fetchone():
|
||||||
|
print("Adding sprint_id column to task table...")
|
||||||
|
db.session.execute(text("ALTER TABLE task ADD COLUMN sprint_id INTEGER"))
|
||||||
|
db.session.execute(text("ALTER TABLE task ADD CONSTRAINT fk_task_sprint FOREIGN KEY (sprint_id) REFERENCES sprint (id)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Check if task_dependency table exists
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name = 'task_dependency'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not result.fetchone():
|
||||||
|
print("Creating task_dependency table...")
|
||||||
|
db.session.execute(text("""
|
||||||
|
CREATE TABLE task_dependency (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
blocked_task_id INTEGER NOT NULL,
|
||||||
|
blocking_task_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (blocked_task_id) REFERENCES task (id),
|
||||||
|
FOREIGN KEY (blocking_task_id) REFERENCES task (id),
|
||||||
|
UNIQUE(blocked_task_id, blocking_task_id),
|
||||||
|
CHECK (blocked_task_id <> blocking_task_id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print("PostgreSQL schema migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during PostgreSQL migration: {e}")
|
||||||
|
if FLASK_AVAILABLE:
|
||||||
|
db.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def migrate_dashboard_system(db_file=None):
|
def migrate_dashboard_system(db_file=None):
|
||||||
"""Migrate to add Dashboard widget system."""
|
"""Migrate to add Dashboard widget system."""
|
||||||
db_path = get_db_path(db_file)
|
db_path = get_db_path(db_file)
|
||||||
@@ -1045,6 +1243,8 @@ def main():
|
|||||||
help='Run only system events migration')
|
help='Run only system events migration')
|
||||||
parser.add_argument('--dashboard', '--dash', action='store_true',
|
parser.add_argument('--dashboard', '--dash', action='store_true',
|
||||||
help='Run only dashboard system migration')
|
help='Run only dashboard system migration')
|
||||||
|
parser.add_argument('--postgresql', '--pg', action='store_true',
|
||||||
|
help='Run only PostgreSQL schema migration')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1081,6 +1281,9 @@ def main():
|
|||||||
elif args.dashboard:
|
elif args.dashboard:
|
||||||
migrate_dashboard_system(db_path)
|
migrate_dashboard_system(db_path)
|
||||||
|
|
||||||
|
elif args.postgresql:
|
||||||
|
migrate_postgresql_schema()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: run all migrations
|
# Default: run all migrations
|
||||||
run_all_migrations(db_path)
|
run_all_migrations(db_path)
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ class TaskStatus(enum.Enum):
|
|||||||
IN_PROGRESS = "In Progress"
|
IN_PROGRESS = "In Progress"
|
||||||
ON_HOLD = "On Hold"
|
ON_HOLD = "On Hold"
|
||||||
COMPLETED = "Completed"
|
COMPLETED = "Completed"
|
||||||
|
ARCHIVED = "Archived"
|
||||||
CANCELLED = "Cancelled"
|
CANCELLED = "Cancelled"
|
||||||
|
|
||||||
# Task priority enumeration
|
# Task priority enumeration
|
||||||
@@ -455,6 +456,7 @@ class Task(db.Model):
|
|||||||
start_date = db.Column(db.Date, nullable=True)
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
due_date = db.Column(db.Date, nullable=True)
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
completed_date = db.Column(db.Date, nullable=True)
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
archived_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
<option value="IN_PROGRESS">In Progress</option>
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
<option value="ON_HOLD">On Hold</option>
|
<option value="ON_HOLD">On Hold</option>
|
||||||
<option value="COMPLETED">Completed</option>
|
<option value="COMPLETED">Completed</option>
|
||||||
|
<option value="ARCHIVED">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div class="management-actions task-actions">
|
<div class="management-actions task-actions">
|
||||||
<button id="add-task-btn" class="btn btn-primary">+ Add Task</button>
|
<button id="add-task-btn" class="btn btn-primary">+ Add Task</button>
|
||||||
<button id="refresh-tasks" class="btn btn-secondary">🔄 Refresh</button>
|
<button id="refresh-tasks" class="btn btn-secondary">🔄 Refresh</button>
|
||||||
|
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">📦 Show Archived</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +45,10 @@
|
|||||||
<div class="stat-number" id="overdue-tasks">0</div>
|
<div class="stat-number" id="overdue-tasks">0</div>
|
||||||
<div class="stat-label">Overdue</div>
|
<div class="stat-label">Overdue</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card" id="archived-stat-card" style="display: none;">
|
||||||
|
<div class="stat-number" id="archived-tasks">0</div>
|
||||||
|
<div class="stat-label">Archived</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Board -->
|
<!-- Task Board -->
|
||||||
@@ -87,6 +92,16 @@
|
|||||||
<!-- Task cards will be populated here -->
|
<!-- Task cards will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="task-column archived-column" data-status="ARCHIVED" style="display: none;">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📦 Archived</h3>
|
||||||
|
<span class="task-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="column-content" id="column-ARCHIVED">
|
||||||
|
<!-- Archived task cards will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading and Error States -->
|
<!-- Loading and Error States -->
|
||||||
@@ -283,6 +298,88 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Archived Column Styles */
|
||||||
|
.archived-column {
|
||||||
|
background: #f1f3f4;
|
||||||
|
border: 2px dashed #9aa0a6;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-column .column-header {
|
||||||
|
border-bottom-color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-column .task-count {
|
||||||
|
background: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archived Task Card Styles */
|
||||||
|
.archived-column .task-card {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-left: 4px solid #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styles */
|
||||||
|
.btn.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-outline.active {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Action Buttons */
|
||||||
|
.task-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-btn, .restore-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-btn:hover {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Date Displays */
|
||||||
|
.task-completed-date, .task-archived-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-completed-date {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-archived-date {
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
.column-content {
|
.column-content {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
@@ -879,6 +976,7 @@ class UnifiedTaskManager {
|
|||||||
};
|
};
|
||||||
this.currentTask = null;
|
this.currentTask = null;
|
||||||
this.sortableInstances = [];
|
this.sortableInstances = [];
|
||||||
|
this.showArchived = false;
|
||||||
this.currentUserId = {{ g.user.id|tojson }};
|
this.currentUserId = {{ g.user.id|tojson }};
|
||||||
this.smartSearch = new SmartTaskSearch();
|
this.smartSearch = new SmartTaskSearch();
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
@@ -905,6 +1003,10 @@ class UnifiedTaskManager {
|
|||||||
document.getElementById('refresh-tasks').addEventListener('click', () => {
|
document.getElementById('refresh-tasks').addEventListener('click', () => {
|
||||||
this.loadTasks();
|
this.loadTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('toggle-archived').addEventListener('click', () => {
|
||||||
|
this.toggleArchivedView();
|
||||||
|
});
|
||||||
|
|
||||||
// Date validation
|
// Date validation
|
||||||
document.getElementById('task-due-date').addEventListener('blur', () => {
|
document.getElementById('task-due-date').addEventListener('blur', () => {
|
||||||
@@ -1287,7 +1389,8 @@ class UnifiedTaskManager {
|
|||||||
'NOT_STARTED': [],
|
'NOT_STARTED': [],
|
||||||
'IN_PROGRESS': [],
|
'IN_PROGRESS': [],
|
||||||
'ON_HOLD': [],
|
'ON_HOLD': [],
|
||||||
'COMPLETED': []
|
'COMPLETED': [],
|
||||||
|
'ARCHIVED': []
|
||||||
};
|
};
|
||||||
|
|
||||||
filteredTasks.forEach(task => {
|
filteredTasks.forEach(task => {
|
||||||
@@ -1404,8 +1507,24 @@ class UnifiedTaskManager {
|
|||||||
card.addEventListener('click', () => this.openTaskModal(task));
|
card.addEventListener('click', () => this.openTaskModal(task));
|
||||||
|
|
||||||
const dueDate = task.due_date ? new Date(task.due_date) : null;
|
const dueDate = task.due_date ? new Date(task.due_date) : null;
|
||||||
const isOverdue = dueDate && dueDate < new Date() && task.status !== 'COMPLETED';
|
const isOverdue = dueDate && dueDate < new Date() && task.status !== 'COMPLETED' && task.status !== 'ARCHIVED';
|
||||||
|
|
||||||
|
// Add archive/restore buttons for completed and archived tasks
|
||||||
|
let actionButtons = '';
|
||||||
|
if (task.status === 'COMPLETED') {
|
||||||
|
actionButtons = `
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="archive-btn" onclick="taskManager.archiveTask(${task.id}); event.stopPropagation();" title="Archive Task">📦</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (task.status === 'ARCHIVED') {
|
||||||
|
actionButtons = `
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="restore-btn" onclick="taskManager.restoreTask(${task.id}); event.stopPropagation();" title="Restore Task">↩️</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="task-card-header">
|
<div class="task-card-header">
|
||||||
<div class="task-title-section">
|
<div class="task-title-section">
|
||||||
@@ -1419,6 +1538,9 @@ class UnifiedTaskManager {
|
|||||||
<span class="task-assignee">${task.assigned_to_name || 'Unassigned'}</span>
|
<span class="task-assignee">${task.assigned_to_name || 'Unassigned'}</span>
|
||||||
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
|
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${task.status === 'COMPLETED' ? `<div class="task-completed-date">Completed: ${formatUserDate(task.completed_date)}</div>` : ''}
|
||||||
|
${task.status === 'ARCHIVED' ? `<div class="task-archived-date">Archived: ${formatUserDate(task.archived_date)}</div>` : ''}
|
||||||
|
${actionButtons}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
@@ -1432,15 +1554,84 @@ class UnifiedTaskManager {
|
|||||||
const total = this.tasks.length;
|
const total = this.tasks.length;
|
||||||
const completed = this.tasks.filter(t => t.status === 'COMPLETED').length;
|
const completed = this.tasks.filter(t => t.status === 'COMPLETED').length;
|
||||||
const inProgress = this.tasks.filter(t => t.status === 'IN_PROGRESS').length;
|
const inProgress = this.tasks.filter(t => t.status === 'IN_PROGRESS').length;
|
||||||
|
const archived = this.tasks.filter(t => t.status === 'ARCHIVED').length;
|
||||||
const overdue = this.tasks.filter(t => {
|
const overdue = this.tasks.filter(t => {
|
||||||
const dueDate = t.due_date ? new Date(t.due_date) : null;
|
const dueDate = t.due_date ? new Date(t.due_date) : null;
|
||||||
return dueDate && dueDate < new Date() && t.status !== 'COMPLETED';
|
return dueDate && dueDate < new Date() && t.status !== 'COMPLETED' && t.status !== 'ARCHIVED';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
document.getElementById('total-tasks').textContent = total;
|
document.getElementById('total-tasks').textContent = total;
|
||||||
document.getElementById('completed-tasks').textContent = completed;
|
document.getElementById('completed-tasks').textContent = completed;
|
||||||
document.getElementById('in-progress-tasks').textContent = inProgress;
|
document.getElementById('in-progress-tasks').textContent = inProgress;
|
||||||
document.getElementById('overdue-tasks').textContent = overdue;
|
document.getElementById('overdue-tasks').textContent = overdue;
|
||||||
|
document.getElementById('archived-tasks').textContent = archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleArchivedView() {
|
||||||
|
this.showArchived = !this.showArchived;
|
||||||
|
const toggleBtn = document.getElementById('toggle-archived');
|
||||||
|
const archivedColumn = document.querySelector('.archived-column');
|
||||||
|
const archivedStatCard = document.getElementById('archived-stat-card');
|
||||||
|
|
||||||
|
if (this.showArchived) {
|
||||||
|
toggleBtn.textContent = '📦 Hide Archived';
|
||||||
|
toggleBtn.classList.add('active');
|
||||||
|
archivedColumn.style.display = 'block';
|
||||||
|
archivedStatCard.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
toggleBtn.textContent = '📦 Show Archived';
|
||||||
|
toggleBtn.classList.remove('active');
|
||||||
|
archivedColumn.style.display = 'none';
|
||||||
|
archivedStatCard.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveTask(taskId) {
|
||||||
|
if (confirm('Are you sure you want to archive this task? It will be moved to the archived section.')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/archive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await this.loadTasks();
|
||||||
|
} else {
|
||||||
|
alert('Failed to archive task: ' + data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving task:', error);
|
||||||
|
alert('Failed to archive task: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreTask(taskId) {
|
||||||
|
if (confirm('Are you sure you want to restore this task? It will be moved back to completed status.')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await this.loadTasks();
|
||||||
|
} else {
|
||||||
|
alert('Failed to restore task: ' + data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring task:', error);
|
||||||
|
alert('Failed to restore task: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleTaskMove(evt) {
|
async handleTaskMove(evt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user