Compare commits

...

6 Commits

Author SHA1 Message Date
indigo 3d67c304a3 Merge department task types into the Task Type management page
Project Settings > Tasks previously only showed the flat custom task
type lists, with department-owned task types manageable only from the
separate Departments tab. The Asset/Shot Task Type sections now show a
merged view (flat + department-owned, tagged with their department),
and the Add/Edit/Delete dialog can create a task type directly under a
custom department instead of just as a general-purpose flat type.
2026-07-24 22:45:25 +08:00
indigo e1dd5c6eae Offer department-owned task types in the shot/asset Add Task dropdowns
ShotDetailPanel and AssetDetailPanel now merge department task types
(filtered to shot/asset departments respectively) into the flat task
type list shown in "Add Task", so types like Animation's blocking or
Composite's first_pass are selectable at creation time instead of only
via editing a task's type afterward. No backend change needed since
task creation already derives department from an owned task type.
2026-07-24 22:45:17 +08:00
indigo 31d17780a4 Add shot/asset typing and owned task types to departments
Departments now carry a type (shot or asset) and own a list of task
types (e.g. Animation: blocking/primary_pass/second_pass, Composite:
first_pass/second_pass, plus a new Simulation department), additive to
the existing flat Custom Task Type system. When a task's type belongs
to a department, its department is derived and kept in sync server-side
across create/update paths; Task Type is now editable in the Task
Detail panel and Department options are filtered to the task's
shot/asset scope.
2026-07-24 08:55:18 +08:00
indigo 9bbd3df53c Add assignee popover to Gantt chart status cells
The Gantt used a status-only EditableTaskStatus, unlike the shot/asset
data tables which pair status with an avatar-popover assignee picker.
Bring the Gantt's task-level EditableTaskStatus to parity (opt-in via a
showAssignee prop so the flat Tasks table's separate assignee column is
unaffected), and widen the frozen status column to fit both controls.
2026-07-22 04:03:42 +08:00
indigo c09710f4e5 Add per-project Department management
Departments are now a customizable per-project list (standard + custom),
matching the existing custom task type/status pattern, instead of a fixed
7-value enum. They're usable directly on tasks (new field, independent of
assignee) and continue to drive team member department roles.
2026-07-22 04:03:33 +08:00
indigo 11e1369f2f Add group-by toggle (Task Type / Shot) to the Schedule Gantt chart
Generalizes the previous task-type-only grouping into rowGroups, keyed
by either task_type or the shot/asset name depending on a new toolbar
toggle. Each row's label shows whichever dimension isn't already the
group header, so switching modes stays readable instead of showing
redundant text. Collapse state resets on mode/project change to avoid
stale group keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:34:19 +08:00
29 changed files with 2590 additions and 239 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Migration script to normalize projects.custom_departments from a flat list of
strings (pre-department-type feature) to a list of objects:
{"name": str, "type": "shot"|"asset", "task_types": [str]}.
Usage:
python migrate_department_task_types.py
"""
import sqlite3
import sys
import json
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def migrate_database():
"""Normalize any legacy plain-string custom_departments entries."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("Projects table not found. Nothing to migrate.")
conn.close()
return
cursor.execute("SELECT id, custom_departments FROM projects")
rows = cursor.fetchall()
normalized_count = 0
for project_id, custom_departments_raw in rows:
if not custom_departments_raw:
continue
try:
custom_departments = json.loads(custom_departments_raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(custom_departments, list) or not custom_departments:
continue
needs_normalization = any(isinstance(d, str) for d in custom_departments)
if not needs_normalization:
continue
normalized = []
for d in custom_departments:
if isinstance(d, str):
print(f" Project {project_id}: normalizing legacy department '{d}' "
f"(defaulting type='shot', task_types=['{d}'] - review if incorrect)")
normalized.append({"name": d, "type": "shot", "task_types": [d]})
else:
normalized.append(d)
cursor.execute(
"UPDATE projects SET custom_departments = ? WHERE id = ?",
(json.dumps(normalized), project_id)
)
normalized_count += 1
conn.commit()
print(f"Migration completed successfully! {normalized_count} project(s) normalized.")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Department Task Types Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Migration script to add the custom_departments column to the projects table.
Usage:
python migrate_project_custom_departments.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add custom_departments column to the projects table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("Projects table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "projects", "custom_departments"):
print("Column custom_departments already exists, skipping...")
else:
print("Adding column: custom_departments")
cursor.execute("ALTER TABLE projects ADD COLUMN custom_departments TEXT")
cursor.execute("UPDATE projects SET custom_departments = '[]' WHERE custom_departments IS NULL")
# project_members.department_role was previously backed by SQLAlchemy's
# Enum(DepartmentRole) type, which stores the enum MEMBER NAME (e.g. "LAYOUT"),
# not its value ("layout"). The old Optional[DepartmentRole] schema silently
# normalized this back to lowercase on read. Now that the column is a plain
# string, normalize any existing uppercase values so they match the standard
# department strings used everywhere else.
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'")
if cursor.fetchone():
department_name_to_value = {
'LAYOUT': 'layout',
'ANIMATION': 'animation',
'LIGHTING': 'lighting',
'COMPOSITE': 'composite',
'MODELING': 'modeling',
'RIGGING': 'rigging',
'SURFACING': 'surfacing',
}
for old_value, new_value in department_name_to_value.items():
cursor.execute(
"UPDATE project_members SET department_role = ? WHERE department_role = ?",
(new_value, old_value)
)
if cursor.rowcount > 0:
print(f"Normalized {cursor.rowcount} project members from '{old_value}' to '{new_value}'")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM projects")
project_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to '[]').")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Project Custom Departments Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Migration script to add the department column to the tasks table.
Usage:
python migrate_task_department.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add department column to the tasks table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
if not cursor.fetchone():
print("Tasks table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "tasks", "department"):
print("Column department already exists, skipping...")
else:
print("Adding column: department")
cursor.execute("ALTER TABLE tasks ADD COLUMN department VARCHAR")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM tasks")
task_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Task Department Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+2 -2
View File
@@ -1,5 +1,5 @@
# Models package
from .user import User, UserRole, DepartmentRole
from .user import User, UserRole
from .project import Project, ProjectMember, ProjectStatus
from .episode import Episode, EpisodeStatus
from .asset import Asset, AssetCategory, AssetStatus
@@ -17,7 +17,7 @@ from .role import Role, Permission, role_permissions, user_roles
__all__ = [
# User models
"User", "UserRole", "DepartmentRole",
"User", "UserRole",
# Project models
"Project", "ProjectMember", "ProjectStatus",
# Episode models
+4 -2
View File
@@ -2,7 +2,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
from .user import DepartmentRole
import enum
@@ -54,6 +53,9 @@ class Project(Base):
# Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
# Custom departments
custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
# Submission configuration per task type
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
@@ -80,7 +82,7 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
department_role = Column(Enum(DepartmentRole), nullable=True)
department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
+1
View File
@@ -56,6 +56,7 @@ class Task(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
-10
View File
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
DEVELOPER = "developer"
class DepartmentRole(str, enum.Enum):
LAYOUT = "layout"
ANIMATION = "animation"
LIGHTING = "lighting"
COMPOSITE = "composite"
MODELING = "modeling"
RIGGING = "rigging"
SURFACING = "surfacing"
class User(Base):
__tablename__ = "users"
+14 -10
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Dict
from typing import List, Dict, Optional
from database import get_db
from models.asset import Asset, AssetCategory
@@ -10,6 +10,7 @@ from models.user import User, UserRole
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
from schemas.task import TaskCreate
from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.asset_soft_deletion import AssetSoftDeletionService
router = APIRouter()
@@ -119,14 +120,14 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_ASSET_TASK_TYPES + custom_types
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]:
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
"""Create default tasks for an asset."""
created_tasks = []
for task_type in task_types:
# Create task name based on type
task_name = f"{asset.name} - {task_type.title()}"
# Create the task
db_task = Task(
project_id=asset.project_id,
@@ -134,12 +135,13 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
task_type=task_type,
name=task_name,
description=f"Default {task_type} task for {asset.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type) if project else None
)
db.add(db_task)
created_tasks.append(db_task)
return created_tasks
@@ -362,7 +364,7 @@ async def create_asset(
):
"""Create a new asset in a project with optional default tasks"""
# Check project access
check_project_access(project_id, current_user, db)
project = check_project_access(project_id, current_user, db)
# Check if asset name already exists in project (exclude soft deleted)
existing_asset = db.query(Asset).filter(
@@ -408,7 +410,7 @@ async def create_asset(
task_types = get_default_asset_task_types(asset.category)
# Create the tasks
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db)
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
task_count = len(created_tasks)
db.commit()
@@ -553,13 +555,15 @@ async def create_asset_task(
# Create the task
task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task(
project_id=asset.project_id,
asset_id=asset.id,
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {asset.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
+515
View File
@@ -1078,6 +1078,521 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project)
# Department Management Endpoints
# Standard departments (read-only): name, whether they apply to shots or assets,
# and the task types they own.
STANDARD_DEPARTMENTS = [
{"name": "layout", "type": "shot", "task_types": ["layout"]},
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
]
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
def _find_custom_department(custom_departments: list, name: str):
"""Find a custom department dict by name, or None."""
for department in custom_departments:
if department["name"] == name:
return department
return None
def _build_all_departments_response(db_project: Project):
"""Helper function to build AllDepartmentsResponse"""
from schemas.department import AllDepartmentsResponse, DepartmentInfo
custom_departments = db_project.custom_departments or []
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
return AllDepartmentsResponse(
departments=standard_infos + custom_infos,
standard_departments=standard_infos,
custom_departments=custom_infos
)
@router.get("/{project_id}/departments")
async def get_all_departments(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_with_db)
):
"""Get all departments (standard + custom) for a project"""
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
async def add_department(
project_id: int,
department_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a new custom department to a project"""
from schemas.department import CustomDepartmentCreate
try:
department_create = CustomDepartmentCreate(**department_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if department_create.department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
)
if _find_custom_department(custom_departments, department_create.department):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' already exists"
)
custom_departments.append({
"name": department_create.department,
"type": department_create.department_type,
"task_types": department_create.task_types
})
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}")
async def update_department(
project_id: int,
department: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Update a custom department name, cascading the rename to members and tasks using it"""
from schemas.department import CustomDepartmentUpdate
from models.task import Task
try:
department_update = CustomDepartmentUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if department != department_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Department in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department_update.old_name)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department_update.old_name}' not found"
)
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' is a standard department"
)
if (department_update.new_name != department_update.old_name
and _find_custom_department(custom_departments, department_update.new_name)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' already exists"
)
existing["name"] = department_update.new_name
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to project members using this department
members_to_update = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department_update.old_name
).all()
for member in members_to_update:
member.department_role = department_update.new_name
# Cascade rename to tasks using this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department_update.old_name
).all()
for task in tasks_to_update:
task.department = department_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update department"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}")
async def delete_department(
project_id: int,
department: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a custom department (blocked if any member or task is currently using it)"""
from models.task import Task
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments cannot be deleted"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if not _find_custom_department(custom_departments, department):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
members_using_department = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department
).all()
tasks_using_department = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department
).all()
if members_using_department or tasks_using_department:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete department '{department}' because it is currently in use",
"department": department,
"member_count": len(members_using_department),
"task_count": len(tasks_using_department)
}
)
custom_departments = [d for d in custom_departments if d["name"] != department]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
async def add_department_task_type(
project_id: int,
department: str,
task_type_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a task type to a custom department"""
from schemas.department import DepartmentTaskTypeCreate
try:
task_type_create = DepartmentTaskTypeCreate(**task_type_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_create.task_type in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
)
existing["task_types"].append(task_type_create.task_type)
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department task type"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
async def rename_department_task_type(
project_id: int,
department: str,
task_type: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
from schemas.department import DepartmentTaskTypeUpdate
from models.task import Task
try:
task_type_update = DepartmentTaskTypeUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if task_type != task_type_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Task type in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_update.old_name not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
)
if (task_type_update.new_name != task_type_update.old_name
and task_type_update.new_name in existing["task_types"]):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
)
existing["task_types"] = [
task_type_update.new_name if t == task_type_update.old_name else t
for t in existing["task_types"]
]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to tasks using this task type within this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type_update.old_name
).all()
for task in tasks_to_update:
task.task_type = task_type_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to rename department task type"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
async def delete_department_task_type(
project_id: int,
department: str,
task_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
from models.task import Task
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type}' not found in department '{department}'"
)
tasks_using_task_type = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type
).all()
if tasks_using_task_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
"department": department,
"task_type": task_type,
"task_count": len(tasks_using_task_type)
}
)
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department task type"
)
return _build_all_departments_response(db_project)
# Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
+16 -10
View File
@@ -13,6 +13,7 @@ from schemas.shot import (
BulkShotCreate, BulkShotResponse, TaskStatusInfo
)
from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.shot_soft_deletion import ShotSoftDeletionService
router = APIRouter()
@@ -131,26 +132,27 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_SHOT_TASK_TYPES + custom_types
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
"""Create default tasks for a shot."""
created_tasks = []
for task_type in task_types:
task_name = f"{shot.name}_{task_type}"
task_description = f"{task_type.title()} task for shot {shot.name}"
task = Task(
project_id=shot.project_id,
episode_id=shot.episode_id,
shot_id=shot.id,
task_type=task_type,
name=task_name,
description=task_description
description=task_description,
department=find_owning_department(project, task_type) if project else None
)
db.add(task)
created_tasks.append(task)
return created_tasks
@@ -420,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
project = db.query(Project).filter(Project.id == episode.project_id).first()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
db.commit()
task_count = len(created_tasks)
@@ -498,7 +501,8 @@ async def create_shots_bulk(
created_shots = []
total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try:
# Create all shots - validation already done above
for i, shot_name in enumerate(shot_names_to_create):
@@ -525,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested
task_count = 0
if bulk_shot.create_default_tasks:
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
task_count = len(created_tasks)
total_tasks_created += task_count
@@ -694,6 +698,7 @@ async def create_shot_task(
# Create the task
task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task(
project_id=shot.project_id,
episode_id=shot.episode_id,
@@ -701,7 +706,8 @@ async def create_shot_task(
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {shot.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
+32 -11
View File
@@ -9,7 +9,7 @@ from datetime import datetime
from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
from models.user import User, UserRole, DepartmentRole
from models.user import User, UserRole
from models.project import Project, ProjectMember
from models.asset import Asset
from models.shot import Shot
@@ -25,6 +25,7 @@ from schemas.task import (
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
from utils.notifications import notification_service
from utils.file_handler import file_handler
from utils.departments import find_owning_department
router = APIRouter()
@@ -248,6 +249,7 @@ async def get_tasks(
"name": task.name,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -330,6 +332,7 @@ async def get_my_tasks(
name=task.name,
task_type=task.task_type,
status=task.status,
department=task.department,
start_date=task.start_date,
deadline=task.deadline,
project_id=task.project_id,
@@ -413,10 +416,16 @@ async def create_task(
# Validate the provided status
if not validate_task_status(db, task.project_id, task_data['status']):
raise HTTPException(
status_code=400,
status_code=400,
detail=f"Invalid status '{task_data['status']}' for this project"
)
# If the task type belongs to a department, that department wins over
# anything explicitly submitted for `department`.
owning_department = find_owning_department(project, task_data.get('task_type'))
if owning_department:
task_data['department'] = owning_department
# Create task
db_task = Task(**task_data)
db.add(db_task)
@@ -740,6 +749,7 @@ async def get_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -826,7 +836,15 @@ async def update_task(
status_code=400,
detail=f"Invalid status '{update_data['status']}' for this project"
)
# If the task type is being changed to one owned by a department, that
# department wins over anything explicitly submitted for `department`.
if 'task_type' in update_data:
project = db.query(Project).filter(Project.id == task.project_id).first()
owning_department = find_owning_department(project, update_data['task_type'])
if owning_department:
update_data['department'] = owning_department
# Update task
for field, value in update_data.items():
setattr(task, field, value)
@@ -866,6 +884,7 @@ async def update_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -958,6 +977,7 @@ async def update_task_status(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
@@ -1024,13 +1044,13 @@ async def assign_task(
# Check if user's department role matches task type (optional validation)
task_to_department_mapping = {
"layout": DepartmentRole.LAYOUT,
"animation": DepartmentRole.ANIMATION,
"lighting": DepartmentRole.LIGHTING,
"compositing": DepartmentRole.COMPOSITE,
"modeling": DepartmentRole.MODELING,
"rigging": DepartmentRole.RIGGING,
"surfacing": DepartmentRole.SURFACING,
"layout": "layout",
"animation": "animation",
"lighting": "lighting",
"compositing": "composite",
"modeling": "modeling",
"rigging": "rigging",
"surfacing": "surfacing",
"simulation": None # Simulation can be handled by multiple departments
}
@@ -1064,6 +1084,7 @@ async def assign_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
+104
View File
@@ -0,0 +1,104 @@
"""
Pydantic schemas for department management
"""
from pydantic import BaseModel, Field, validator
from typing import List, Literal
import re
DEPARTMENT_NAME_PATTERN = r'^[a-z0-9_]{2,50}$'
class CustomDepartmentCreate(BaseModel):
"""Schema for creating a new custom department"""
department: str = Field(..., min_length=2, max_length=50, description="Department name")
department_type: Literal["shot", "asset"] = Field(..., description="Whether this department applies to shots or assets")
task_types: List[str] = Field(default_factory=list, description="Task types owned by this department")
@validator('department')
def validate_department_name(cls, v):
"""Validate department name format"""
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
@validator('task_types', each_item=True)
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class CustomDepartmentUpdate(BaseModel):
"""Schema for updating a custom department name"""
old_name: str = Field(..., description="Current department name")
new_name: str = Field(..., min_length=2, max_length=50, description="New department name")
@validator('new_name')
def validate_department_name(cls, v):
"""Validate department name format"""
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentTaskTypeCreate(BaseModel):
"""Schema for adding a task type to a custom department"""
task_type: str = Field(..., min_length=2, max_length=50, description="Task type name")
@validator('task_type')
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentTaskTypeUpdate(BaseModel):
"""Schema for renaming a task type within a custom department"""
old_name: str = Field(..., description="Current task type name")
new_name: str = Field(..., min_length=2, max_length=50, description="New task type name")
@validator('new_name')
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentInfo(BaseModel):
"""A single department: its name, whether it applies to shots or assets, and its owned task types"""
name: str
type: Literal["shot", "asset"]
task_types: List[str]
class AllDepartmentsResponse(BaseModel):
"""Schema for response containing all departments (standard + custom)"""
departments: List[DepartmentInfo] = Field(..., description="All departments")
standard_departments: List[DepartmentInfo] = Field(..., description="Standard departments (read-only)")
custom_departments: List[DepartmentInfo] = Field(..., description="Custom departments")
class DepartmentInUseError(BaseModel):
"""Schema for error when trying to delete a department in use"""
error: str = Field(..., description="Error message")
department: str = Field(..., description="Department that is in use")
member_count: int = Field(..., description="Number of project members using this department")
task_count: int = Field(..., description="Number of tasks using this department")
class DepartmentTaskTypeInUseError(BaseModel):
"""Schema for error when trying to delete a department task type in use"""
error: str = Field(..., description="Error message")
department: str = Field(..., description="Department the task type belongs to")
task_type: str = Field(..., description="Task type that is in use")
task_count: int = Field(..., description="Number of tasks using this task type")
+2 -35
View File
@@ -5,7 +5,6 @@ from enum import Enum
import re
from models.project import ProjectStatus, ProjectType
from models.user import DepartmentRole
# Technical Specifications Schemas
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
# Default delivery movie specifications per department
DEFAULT_DELIVERY_MOVIE_SPECS = {
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectCreate(ProjectBase):
pass
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return v
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectMemberBase(BaseModel):
user_id: int
department_role: Optional[DepartmentRole] = None
department_role: Optional[str] = None
class ProjectMemberCreate(ProjectMemberBase):
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberUpdate(BaseModel):
department_role: Optional[DepartmentRole] = None
department_role: Optional[str] = None
class ProjectMemberResponse(ProjectMemberBase):
+3 -1
View File
@@ -4,7 +4,6 @@ from datetime import date, datetime
from enum import Enum
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
from models.user import DepartmentRole
class TaskBase(BaseModel):
@@ -14,6 +13,7 @@ class TaskBase(BaseModel):
start_date: Optional[date] = None
deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
class TaskCreate(TaskBase):
@@ -31,6 +31,7 @@ class TaskUpdate(BaseModel):
start_date: Optional[date] = None
deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
assigned_user_id: Optional[int] = None
@@ -70,6 +71,7 @@ class TaskListResponse(BaseModel):
name: str
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
start_date: Optional[date] = None
deadline: Optional[date] = None
project_id: int
+21
View File
@@ -0,0 +1,21 @@
"""Shared helpers for resolving the department that owns a given task type."""
from typing import Optional
def find_owning_department(db_project, task_type: str) -> Optional[str]:
"""Return the name of the department (standard or custom) whose task_types
list contains task_type for this project, or None if no department owns it."""
if not task_type:
return None
from routers.projects import STANDARD_DEPARTMENTS
for department in STANDARD_DEPARTMENTS:
if task_type in department["task_types"]:
return department["name"]
for department in (db_project.custom_departments or []):
if task_type in department.get("task_types", []):
return department["name"]
return None
@@ -293,6 +293,7 @@ import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/se
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task {
id: number
@@ -321,6 +322,7 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state
const asset = ref<Asset | null>(null)
@@ -363,9 +365,15 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
// Task types offered in "Add Task" include the flat asset task type list
// plus every task type owned by an asset department (e.g. Modeling's own
// task types), deduped against types the asset already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
const taskStatusCounts = computed(() => {
@@ -394,7 +402,8 @@ const loadAssetDetails = async () => {
isLoading.value = true
error.value = null
asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) {
try {
@@ -104,7 +104,7 @@
import { computed } from 'vue'
import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench,
surfacing: Paintbrush
}
return icons[department] || Box
// Fallback for project-custom departments not in the standard icon map above
return icons[department] || Tag
}
const getFrameRateLabel = (frameRate: number) => {
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user'
import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth'
interface Props {
@@ -248,16 +249,14 @@ const emit = defineEmits<{
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = [
{ value: 'layout', label: 'Layout' },
{ value: 'animation', label: 'Animation' },
{ value: 'lighting', label: 'Lighting' },
{ value: 'composite', label: 'Composite' },
{ value: 'modeling', label: 'Modeling' },
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
const departmentRoles = computed(() => {
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
value: department,
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}))
})
// State
const members = ref<ProjectMember[]>([])
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
// Lifecycle
onMounted(() => {
loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
})
</script>
@@ -20,6 +20,19 @@
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="option in GROUP_BY_OPTIONS"
:key="option"
:variant="groupBy === option ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs"
@click="groupBy = option"
>
{{ groupByLabel(option) }}
</Button>
</div>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="scale in SCALES"
@@ -73,7 +86,7 @@
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="taskTypeGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
<div v-if="rowGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
</div>
@@ -94,24 +107,24 @@
</div>
</div>
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div v-for="group in rowGroups" :key="group.key">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.taskType)"
@click="toggleGroup(group.key)"
>
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
<component
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
:is="isCollapsed(group.key) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
<span class="truncate">{{ group.label }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
@@ -119,14 +132,17 @@
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
{{ task.shot_name || task.asset_name || task.name }}
{{ taskRowLabel(task) }}
</div>
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
<EditableTaskStatus
:task-id="task.id"
:status="task.status"
:project-id="projectId"
show-assignee
:assigned-user-id="task.assigned_user_id"
@status-updated="handleStatusUpdated"
@assignment-updated="handleAssignmentUpdated"
/>
</div>
</div>
@@ -170,11 +186,11 @@
></div>
<!-- Rows -->
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div v-for="group in rowGroups" :key="group.key">
<div
class="relative border-b bg-muted/40 cursor-pointer"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.taskType)"
@click="toggleGroup(group.key)"
>
<div
v-if="group.barLeft !== null"
@@ -183,7 +199,7 @@
></div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
@@ -215,7 +231,7 @@
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
>
{{ task.shot_name || task.asset_name || task.name }}
{{ taskRowLabel(task) }}
</span>
</div>
<!-- Submission date markers -->
@@ -293,7 +309,7 @@ const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56
const STATUS_COLUMN_WIDTH = 176 // matches w-44
const STATUS_COLUMN_WIDTH = 200 // status select (130px) + assignee avatar button, with padding
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
const HEADER_HEIGHT = 44
const GROUP_ROW_HEIGHT = 32
@@ -539,23 +555,43 @@ const todayLeft = computed(() => {
return dateToLeft(todayUtc)
})
interface TaskTypeGroup {
taskType: string
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
type GroupBy = typeof GROUP_BY_OPTIONS[number]
const groupBy = ref<GroupBy>('taskType')
function groupByLabel(mode: GroupBy): string {
return mode === 'taskType' ? 'Task Type' : 'Shot'
}
function entityName(task: TaskListItem): string {
return task.shot_name || task.asset_name || task.name
}
// The row label shows whichever dimension ISN'T already the group header:
// task type when grouped by shot, and the shot/asset name when grouped by task type.
function taskRowLabel(task: TaskListItem): string {
return groupBy.value === 'taskType' ? entityName(task) : formatTaskType(task.task_type)
}
interface RowGroup {
key: string
label: string
tasks: TaskListItem[]
barLeft: number | null
barWidth: number | null
}
const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
const byType = new Map<string, TaskListItem[]>()
const rowGroups = computed<RowGroup[]>(() => {
const byKey = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) {
if (!byType.has(t.task_type)) byType.set(t.task_type, [])
byType.get(t.task_type)!.push(t)
const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key)!.push(t)
}
return Array.from(byType.entries())
return Array.from(byKey.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([taskType, groupTasks]) => {
.map(([key, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
@@ -571,18 +607,19 @@ const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
}
return { taskType, tasks: sorted, barLeft, barWidth }
const label = groupBy.value === 'taskType' ? formatTaskType(key) : key
return { key, label, tasks: sorted, barLeft, barWidth }
})
})
function isCollapsed(taskType: string): boolean {
return collapsedGroups.value.has(taskType)
function isCollapsed(key: string): boolean {
return collapsedGroups.value.has(key)
}
function toggleGroup(taskType: string) {
function toggleGroup(key: string) {
const next = new Set(collapsedGroups.value)
if (next.has(taskType)) next.delete(taskType)
else next.add(taskType)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedGroups.value = next
}
@@ -591,9 +628,13 @@ const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? new Set()
: new Set(taskTypeGroups.value.map(g => g.taskType))
: new Set(rowGroups.value.map(g => g.key))
}
watch(groupBy, () => {
collapsedGroups.value = new Set()
})
function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
@@ -608,6 +649,11 @@ function handleStatusUpdated(taskId: number, newStatus: string) {
if (task) task.status = newStatus
}
function handleAssignmentUpdated(taskId: number, userId: number | null) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assigned_user_id = userId ?? undefined
}
// --- Drag to reschedule ---
interface DragState {
@@ -745,6 +791,7 @@ watch(() => props.projectId, () => {
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
@@ -2,9 +2,9 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Custom Task Types</h3>
<h3 class="text-lg font-semibold">Task Types</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom task types beyond the standard types to adapt the pipeline to your project needs
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
</p>
</div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0">
<template v-if="assetTaskTypeRows.length > 0">
<div
v-for="taskType in assetTaskTypes"
:key="taskType"
v-for="row in assetTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('asset', taskType)"
@click="openEditDialog('asset', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('asset', taskType)"
@click="handleDelete('asset', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0">
<template v-if="shotTaskTypeRows.length > 0">
<div
v-for="taskType in shotTaskTypes"
:key="taskType"
v-for="row in shotTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('shot', taskType)"
@click="openEditDialog('shot', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('shot', taskType)"
@click="handleDelete('shot', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -134,9 +128,9 @@
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} {{ dialogCategory === 'asset' ? 'Asset' : 'Shot' }} Task Type
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.'
: 'Update the task type name. This will update all existing tasks using this type.'
{{ dialogMode === 'add'
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.'
: 'Update the task type name. This will update all existing tasks using this type.'
}}
</DialogDescription>
</DialogHeader>
@@ -155,7 +149,25 @@
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
3-50 characters, lowercase alphanumeric with underscores only
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Department (optional)</Label>
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
<SelectTrigger>
<SelectValue placeholder="None (general purpose)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (general purpose)</SelectItem>
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
{{ formatTaskTypeName(department.name) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
</p>
</div>
</div>
@@ -173,14 +185,15 @@
</Dialog>
<!-- Delete Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting.value) {
// Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = ''
categoryToDelete = ''
departmentToDelete = ''
deleteError = ''
}
}"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
import { departmentService, type DepartmentInfo } from '@/services/department'
import { useDepartmentsStore } from '@/stores/departments'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State
const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('')
const originalTaskTypeName = ref('')
const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
const dialogCustomDepartments = computed(() => {
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
})
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
const rows = new Map<string, TaskTypeRow>()
for (const t of flatAll) {
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
}
for (const department of departments) {
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
for (const t of department.task_types) {
const existing = rows.get(t)
if (existing) {
existing.department = department.name
existing.isStandard = existing.isStandard || isDeptStandard
} else {
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
}
}
}
return Array.from(rows.values())
}
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value
const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
})
// Methods
const loadTaskTypes = async () => {
try {
isLoading.value = true
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
const [types] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
departmentsStore.fetchProjectDepartments(props.projectId, true)
])
taskTypes.value = types
} catch (error: any) {
console.error('Failed to load task types:', error)
toast({
@@ -298,65 +361,60 @@ const loadTaskTypes = async () => {
}
}
const isStandardAssetType = (taskType: string): boolean => {
return standardAssetTypes.value.includes(taskType)
}
const isStandardShotType = (taskType: string): boolean => {
return standardShotTypes.value.includes(taskType)
}
const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ')
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 3) {
validationError.value = 'Task type name must be at least 3 characters'
if (name.length < minLength) {
validationError.value = `Task type name must be at least ${minLength} characters`
return
}
if (name.length > 50) {
validationError.value = 'Task type name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
// Check for duplicates (only if adding or changing name)
// Check for duplicates against the merged (flat + department) rows for this category
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
if (existingTypes.includes(name)) {
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists'
return
}
}
validationError.value = ''
}
const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add'
dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit'
dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType
originalTaskTypeName.value = taskType
validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => {
isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
@@ -372,45 +432,58 @@ const closeDialog = () => {
const handleDialogSave = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
console.log('Add task type response:', response)
taskTypes.value = response
if (dialogDepartment.value) {
const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
console.log('Update task type response:', response)
taskTypes.value = response
if (editingDepartment.value) {
const response = await departmentService.renameDepartmentTaskType(
props.projectId,
editingDepartment.value,
originalTaskTypeName.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
@@ -427,9 +500,10 @@ const handleDialogSave = async () => {
}
}
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
taskTypeToDelete.value = taskType
categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = ''
isDeleteDialogOpen.value = true
}
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
@@ -454,35 +529,44 @@ const confirmDelete = async () => {
return
}
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
if (departmentToDeleteLocal) {
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeToDeleteLocal}" deleted successfully`
})
emit('updated')
// Close dialog and clear values
isDeleteDialogOpen.value = false
taskTypeToDelete.value = ''
categoryToDelete.value = ''
categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
const detail = errorData?.detail
if (detail?.task_count) {
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
} else if (typeof detail === 'string') {
deleteError.value = detail
} else {
deleteError.value = errorData?.detail || 'Failed to delete task type'
deleteError.value = 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteError.value,
@@ -0,0 +1,672 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Departments</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
</p>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Content -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-muted-foreground" />
<h4 class="font-semibold">All Departments</h4>
</div>
<Button size="sm" @click="openAddDialog">
<Plus class="h-4 w-4 mr-2" />
Add Department
</Button>
</div>
<div class="border rounded-lg divide-y">
<template v-if="allDepartments.length > 0">
<div
v-for="department in allDepartments"
:key="department.name"
class="p-3 hover:bg-muted/50 transition-colors space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium capitalize">{{ formatName(department.name) }}</span>
<Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
</div>
<div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
<Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
<Pencil class="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" @click="handleDelete(department.name)">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 pl-1">
<Badge
v-for="taskType in department.task_types"
:key="taskType"
variant="secondary"
class="text-xs font-normal capitalize gap-1"
>
{{ formatName(taskType) }}
<button
v-if="!isStandardDepartment(department.name)"
class="hover:text-destructive"
@click="handleDeleteTaskType(department.name, taskType)"
>
<X class="h-3 w-3" />
</button>
</Badge>
<span v-if="department.task_types.length === 0" class="text-xs text-muted-foreground">
No task types defined
</span>
<Button
v-if="!isStandardDepartment(department.name)"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
@click="openAddTaskTypeDialog(department.name)"
>
<Plus class="h-3 w-3 mr-1" />
Add Task Type
</Button>
</div>
</div>
</template>
<div v-else class="p-4 text-center text-sm text-muted-foreground">
No departments defined
</div>
</div>
</div>
<!-- Add/Edit Department Dialog -->
<Dialog :open="isDialogOpen" @update:open="closeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
: 'Update the department name. This will update all team members and tasks using this department.'
}}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="departmentName">Department Name</Label>
<Input
id="departmentName"
v-model="departmentName"
placeholder="e.g., fx, previz, matchmove"
:class="{ 'border-destructive': validationError }"
@input="validateDepartmentName"
/>
<p v-if="validationError" class="text-sm text-destructive">
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
2-50 characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Type</Label>
<Select v-model="departmentType">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shot">Shot</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Shot departments apply to shot tasks; asset departments apply to asset tasks.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeDialog">
Cancel
</Button>
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Add Task Type Dialog -->
<Dialog :open="isTaskTypeDialogOpen" @update:open="closeTaskTypeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Add Task Type to "{{ formatName(taskTypeDepartment) }}"</DialogTitle>
<DialogDescription>
Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="taskTypeName">Task Type Name</Label>
<Input
id="taskTypeName"
v-model="taskTypeName"
placeholder="e.g., blocking, first_pass"
:class="{ 'border-destructive': taskTypeValidationError }"
@input="validateTaskTypeName"
/>
<p v-if="taskTypeValidationError" class="text-sm text-destructive">
{{ taskTypeValidationError }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeTaskTypeDialog">
Cancel
</Button>
<Button @click="handleAddTaskType" :disabled="!isTaskTypeNameValid || isSavingTaskType">
<div v-if="isSavingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Department Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting) {
departmentToDelete = ''
deleteError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Department</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the department "{{ departmentToDelete }}"?
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
{{ deleteError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDelete"
:disabled="isDeleting"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Delete Task Type Confirmation Dialog -->
<AlertDialog
:open="isDeleteTaskTypeDialogOpen"
@update:open="(open) => {
isDeleteTaskTypeDialogOpen = open
if (!open && !isDeletingTaskType) {
taskTypeToDelete = null
deleteTaskTypeError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the task type "{{ taskTypeToDelete?.taskType }}" from "{{ taskTypeToDelete?.department }}"?
<span v-if="deleteTaskTypeError" class="block mt-2 text-destructive font-medium">
{{ deleteTaskTypeError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDeleteTaskType"
:disabled="isDeletingTaskType"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeletingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useDepartmentsStore } from '@/stores/departments'
import { departmentService, type DepartmentType } from '@/services/department'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
updated: []
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
// State
const isLoading = ref(true)
// Department dialog state
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const departmentName = ref('')
const departmentType = ref<DepartmentType | ''>('')
const originalDepartmentName = ref('')
const validationError = ref('')
const isSaving = ref(false)
// Task type dialog state
const isTaskTypeDialogOpen = ref(false)
const taskTypeDepartment = ref('')
const taskTypeName = ref('')
const taskTypeValidationError = ref('')
const isSavingTaskType = ref(false)
// Delete department dialog state
const isDeleteDialogOpen = ref(false)
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
// Delete task type dialog state
const isDeleteTaskTypeDialogOpen = ref(false)
const taskTypeToDelete = ref<{ department: string; taskType: string } | null>(null)
const deleteTaskTypeError = ref('')
const isDeletingTaskType = ref(false)
// Computed
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
const isDepartmentNameValid = computed(() => {
return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
})
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
})
// Methods
const loadDepartments = async () => {
try {
isLoading.value = true
await departmentsStore.fetchProjectDepartments(props.projectId, true)
} catch (error: any) {
console.error('Failed to load departments:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load departments',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const isStandardDepartment = (department: string): boolean => {
return standardDepartments.value.some(d => d.name === department)
}
const formatName = (name: string): string => {
return name.replace(/_/g, ' ')
}
const validateDepartmentName = () => {
const name = departmentName.value.trim()
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 2) {
validationError.value = 'Department name must be at least 2 characters'
return
}
if (name.length > 50) {
validationError.value = 'Department name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
return
}
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
if (allDepartmentNames.value.includes(name)) {
validationError.value = 'A department with this name already exists'
return
}
}
validationError.value = ''
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
if (name.length === 0) {
taskTypeValidationError.value = ''
return
}
if (name.length < 2 || name.length > 50) {
taskTypeValidationError.value = 'Task type name must be 2-50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
taskTypeValidationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
const department = allDepartments.value.find(d => d.name === taskTypeDepartment.value)
if (department?.task_types.includes(name)) {
taskTypeValidationError.value = 'A task type with this name already exists in this department'
return
}
taskTypeValidationError.value = ''
}
const openAddDialog = () => {
dialogMode.value = 'add'
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (department: string) => {
dialogMode.value = 'edit'
departmentName.value = department
originalDepartmentName.value = department
validationError.value = ''
isDialogOpen.value = true
}
const closeDialog = () => {
isDialogOpen.value = false
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
}
const openAddTaskTypeDialog = (department: string) => {
taskTypeDepartment.value = department
taskTypeName.value = ''
taskTypeValidationError.value = ''
isTaskTypeDialogOpen.value = true
}
const closeTaskTypeDialog = () => {
isTaskTypeDialogOpen.value = false
taskTypeDepartment.value = ''
taskTypeName.value = ''
taskTypeValidationError.value = ''
}
const handleDialogSave = async () => {
validateDepartmentName()
if (!isDepartmentNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await departmentService.addDepartment(props.projectId, {
department: departmentName.value.trim(),
department_type: departmentType.value as DepartmentType
})
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentName.value}" added successfully`
})
} else {
const response = await departmentService.updateDepartment(
props.projectId,
originalDepartmentName.value,
{
old_name: originalDepartmentName.value,
new_name: departmentName.value.trim()
}
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
console.error('Failed to save department:', error)
const errorMessage = error.response?.data?.detail || 'Failed to save department'
validationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
const handleAddTaskType = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSavingTaskType.value = true
const response = await departmentService.addDepartmentTaskType(
props.projectId,
taskTypeDepartment.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
emit('updated')
closeTaskTypeDialog()
} catch (error: any) {
console.error('Failed to add task type:', error)
const errorMessage = error.response?.data?.detail || 'Failed to add task type'
taskTypeValidationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSavingTaskType.value = false
}
}
const handleDelete = (department: string) => {
departmentToDelete.value = department
deleteError.value = ''
isDeleteDialogOpen.value = true
}
const confirmDelete = async () => {
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
deleteError.value = ''
if (!departmentToDeleteLocal) {
deleteError.value = 'Department name is missing. Please try again.'
isDeleting.value = false
return
}
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentToDeleteLocal}" deleted successfully`
})
emit('updated')
isDeleteDialogOpen.value = false
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete department:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
const { task_count, member_count } = errorData.detail
const parts = []
if (member_count) parts.push(`${member_count} team member(s)`)
if (task_count) parts.push(`${task_count} task(s)`)
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
} else {
deleteError.value = errorData?.detail || 'Failed to delete department'
}
toast({
title: 'Error',
description: deleteError.value,
variant: 'destructive'
})
} finally {
isDeleting.value = false
}
}
const handleDeleteTaskType = (department: string, taskType: string) => {
taskTypeToDelete.value = { department, taskType }
deleteTaskTypeError.value = ''
isDeleteTaskTypeDialogOpen.value = true
}
const confirmDeleteTaskType = async () => {
const target = taskTypeToDelete.value
if (!target) return
try {
isDeletingTaskType.value = true
deleteTaskTypeError.value = ''
const response = await departmentService.removeDepartmentTaskType(props.projectId, target.department, target.taskType)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${target.taskType}" deleted successfully`
})
emit('updated')
isDeleteTaskTypeDialogOpen.value = false
taskTypeToDelete.value = null
deleteTaskTypeError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined) {
deleteTaskTypeError.value = `Cannot delete: ${errorData.detail.task_count} task(s) are using this task type`
} else {
deleteTaskTypeError.value = errorData?.detail || 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteTaskTypeError.value,
variant: 'destructive'
})
} finally {
isDeletingTaskType.value = false
}
}
// Lifecycle
onMounted(() => {
loadDepartments()
})
</script>
@@ -341,6 +341,7 @@ import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
@@ -375,6 +376,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
@@ -435,9 +437,15 @@ const canUploadReferences = computed(() => {
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
// Task types offered in "Add Task" include the flat shot task type list
// plus every task type owned by a shot department (e.g. Animation's own
// task types), deduped against types the shot already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'shot')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
// Methods
@@ -446,6 +454,7 @@ const loadShotDetails = async () => {
isLoading.value = true
error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers()
@@ -1,12 +1,12 @@
<template>
<div class="relative"
<div class="relative flex items-center gap-1"
>
<Select
:model-value="currentStatusId"
<Select
:model-value="currentStatusId"
@update:model-value="handleStatusChange"
:disabled="isUpdating || isLoadingStatuses"
>
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
:style="{ backgroundColor: currentStatusObject.color }"
@@ -19,14 +19,14 @@
</SelectTrigger>
<SelectContent @contextmenu.prevent>
<!-- System Statuses -->
<SelectItem
v-for="statusOption in allStatusOptions"
:key="statusOption.id"
<SelectItem
v-for="statusOption in allStatusOptions"
:key="statusOption.id"
:value="statusOption.id"
>
<div class="flex items-center gap-2">
<!-- Color indicator -->
<!-- <div
<!-- <div
v-if="statusOption.color"
class="w-3 h-3 rounded-full border border-border"
:style="{ backgroundColor: statusOption.color }"
@@ -36,10 +36,116 @@
</SelectItem>
</SelectContent>
</Select>
<!-- User Assignment Button -->
<div v-if="showAssignee" @click.stop>
<Popover>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-accent relative"
:disabled="isUpdating"
@click.stop="ensureMembersLoaded"
>
<Avatar class="h-4 w-4" v-if="assignedUser">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<User class="h-3 w-3" v-else />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
<div class="space-y-2">
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
<!-- Current Assignment Display with X button -->
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground">Current</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
@click.stop="handleAssignUser(null)"
:disabled="isAssigning"
>
<X class="h-3 w-3" />
</Button>
</div>
<!-- Search Input -->
<div class="relative">
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search members..."
class="pl-7 h-8 text-xs"
/>
</div>
<!-- Loading state -->
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2 text-sm">Loading members...</span>
</div>
<!-- Error state -->
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
No project members found
<Button
variant="outline"
size="sm"
class="mt-2"
@click="loadProjectMembers"
>
Retry
</Button>
</div>
<!-- Content when members are loaded -->
<template v-else>
<!-- Project members list -->
<div class="max-h-64 overflow-y-auto">
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
No matching members found
</div>
<Button
v-else
v-for="member in filteredProjectMembers"
:key="member.user_id"
variant="ghost"
size="sm"
class="w-full justify-start h-10"
@click="handleAssignUser(member.user_id)"
:disabled="isAssigning"
>
<Avatar class="h-8 w-8 mr-2">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
</div>
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Loading indicator -->
<div
v-if="isUpdating || isLoadingStatuses"
<div
v-if="isUpdating || isLoadingStatuses"
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
@@ -56,11 +162,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import type { ProjectMember } from '@/services/project'
interface StatusOption {
id: string
@@ -73,10 +191,13 @@ interface Props {
taskId: number
status: TaskStatus | string
projectId: number
showAssignee?: boolean
assignedUserId?: number | null
}
interface Emits {
(e: 'status-updated', taskId: number, newStatus: string): void
(e: 'assignment-updated', taskId: number, userId: number | null): void
}
const props = defineProps<Props>()
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const { getAvatarUrl } = useAvatarUrl()
const isUpdating = ref(false)
const isAssigning = ref(false)
const searchQuery = ref('')
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const filteredProjectMembers = computed(() => {
if (!searchQuery.value.trim()) {
return projectMembers.value
}
const query = searchQuery.value.toLowerCase().trim()
return projectMembers.value.filter(member => {
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
const departmentRole = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || departmentRole.includes(query)
})
})
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
const getUserInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const loadProjectMembers = async () => {
try {
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
}
}
const ensureMembersLoaded = () => {
if (projectMembers.value.length === 0) {
loadProjectMembers()
}
}
const handleAssignUser = async (userId: number | null) => {
isAssigning.value = true
try {
if (userId) {
await taskService.assignTask(props.taskId, userId)
} else {
await taskService.updateTask(props.taskId, { assigned_user_id: 0 })
}
emit('assignment-updated', props.taskId, userId)
// Close popover by simulating click outside after assignment
setTimeout(() => {
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}, 100)
} catch (error) {
console.error('Failed to assign task:', error)
} finally {
isAssigning.value = false
}
}
// Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
@@ -119,11 +119,35 @@
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<Label class="text-muted-foreground">Type</Label>
<p class="text-sm mt-1">
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
</p>
<div class="mt-1">
<Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
<SelectTrigger class="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="taskType in taskTypeOptions" :key="taskType" :value="taskType">
{{ formatTaskType(taskType) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label class="text-muted-foreground">Department</Label>
<div class="mt-1">
<Select :model-value="localDepartment || 'none'" @update:model-value="(value) => handleDepartmentChange(value === 'none' ? '' : (value as string))">
<SelectTrigger class="h-8">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="department in departmentOptions" :key="department" :value="department">
{{ formatDepartment(department) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div></div>
<div>
<Label class="text-muted-foreground">Start Date</Label>
<div class="mt-1">
@@ -341,6 +365,8 @@ import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue'
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import { useDepartmentsStore } from '@/stores/departments'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -358,6 +384,7 @@ const emit = defineEmits<{
const { toast } = useToast()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const departmentsStore = useDepartmentsStore()
const task = ref<Task | null>(null)
const loading = ref(false)
@@ -365,6 +392,9 @@ const error = ref<string | null>(null)
const localStatus = ref('')
const localStartDate = ref('')
const localDeadline = ref('')
const localDepartment = ref('')
const flatShotTaskTypes = ref<string[]>([])
const flatAssetTaskTypes = ref<string[]>([])
const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([])
@@ -392,6 +422,34 @@ const canSubmitWork = computed(() => {
const canReassign = computed(() => isCoordinatorOrAdmin.value)
// Departments are type-scoped (shot vs asset); a standalone task (neither
// shot nor asset) falls back to the unfiltered list.
const departmentOptions = computed(() => {
if (!task.value) return []
if (task.value.shot_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'shot').map(d => d.name)
if (task.value.asset_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'asset').map(d => d.name)
return departmentsStore.getAllDepartmentOptions(task.value.project_id)
})
// Task Type options come from the current department's owned task types when
// it has any; otherwise fall back to the existing flat asset/shot task type list.
const taskTypeOptions = computed(() => {
if (!task.value) return []
const departmentTaskTypes = localDepartment.value
? departmentsStore.getDepartmentTaskTypes(task.value.project_id, localDepartment.value)
: []
if (departmentTaskTypes.length > 0) return departmentTaskTypes
const flatTypes = task.value.shot_id ? flatShotTaskTypes.value : flatAssetTaskTypes.value
// Always include the task's current type, even if it isn't in either list
// (e.g. a legacy or since-removed value), so the Select never shows blank.
return flatTypes.includes(task.value.task_type) ? flatTypes : [task.value.task_type, ...flatTypes]
})
function formatDepartment(department: string): string {
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}
async function loadTask() {
loading.value = true
error.value = null
@@ -400,6 +458,12 @@ async function loadTask() {
localStatus.value = task.value.status
localStartDate.value = task.value.start_date || ''
localDeadline.value = task.value.deadline || ''
localDepartment.value = task.value.department || ''
departmentsStore.fetchProjectDepartments(task.value.project_id)
customTaskTypeService.getAllTaskTypes(task.value.project_id).then(types => {
flatShotTaskTypes.value = types.shot_task_types
flatAssetTaskTypes.value = types.asset_task_types
}).catch(err => console.error('Failed to load task types:', err))
} catch (err: any) {
console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task'
@@ -483,6 +547,74 @@ async function handleDateChange(field: 'start_date' | 'deadline', value: string)
}
}
async function handleDepartmentChange(value: string) {
if (!task.value) return
const previousDepartment = task.value.department
const previousTaskType = task.value.task_type
// If the current task type doesn't belong to the newly-picked department
// (and that department has its own task types), reset to its first one so
// department and task type stay consistent.
const newDepartmentTaskTypes = value ? departmentsStore.getDepartmentTaskTypes(task.value.project_id, value) : []
const needsTaskTypeReset = newDepartmentTaskTypes.length > 0 && !newDepartmentTaskTypes.includes(task.value.task_type)
const payload: Record<string, any> = { department: value || null }
if (needsTaskTypeReset) {
payload.task_type = newDepartmentTaskTypes[0]
}
try {
const updated = await taskService.updateTask(props.taskId, payload as any)
task.value.department = updated.department
task.value.task_type = updated.task_type
localDepartment.value = updated.department || ''
emit('taskUpdated')
toast({
title: 'Success',
description: 'Task department updated successfully'
})
} catch (error: any) {
console.error('Error updating department:', error)
localDepartment.value = previousDepartment || ''
task.value.task_type = previousTaskType
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update task department',
variant: 'destructive'
})
}
}
async function handleTaskTypeChange(value: string) {
if (!task.value || value === task.value.task_type) return
const previousTaskType = task.value.task_type
const previousDepartment = task.value.department
try {
const updated = await taskService.updateTask(props.taskId, { task_type: value } as any)
task.value.task_type = updated.task_type
task.value.department = updated.department
localDepartment.value = updated.department || ''
emit('taskUpdated')
toast({
title: 'Success',
description: 'Task type updated successfully'
})
} catch (error: any) {
console.error('Error updating task type:', error)
task.value.task_type = previousTaskType
task.value.department = previousDepartment
localDepartment.value = previousDepartment || ''
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update task type',
variant: 'destructive'
})
}
}
async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return
+87
View File
@@ -0,0 +1,87 @@
import { apiClient } from './api'
export type DepartmentType = 'shot' | 'asset'
export interface DepartmentInfo {
name: string
type: DepartmentType
task_types: string[]
}
export interface AllDepartmentsResponse {
departments: DepartmentInfo[]
standard_departments: DepartmentInfo[]
custom_departments: DepartmentInfo[]
}
export interface CustomDepartmentCreate {
department: string
department_type: DepartmentType
task_types?: string[]
}
export interface CustomDepartmentUpdate {
old_name: string
new_name: string
}
export interface DepartmentInUseError {
error: string
department: string
member_count: number
task_count: number
}
export interface DepartmentTaskTypeInUseError {
error: string
department: string
task_type: string
task_count: number
}
export const departmentService = {
async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> {
const response = await apiClient.get(`/projects/${projectId}/departments`)
return response.data
},
async addDepartment(projectId: number, data: CustomDepartmentCreate): Promise<AllDepartmentsResponse> {
const response = await apiClient.post(`/projects/${projectId}/departments`, data)
return response.data
},
async updateDepartment(projectId: number, department: string, data: CustomDepartmentUpdate): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}`, data)
return response.data
},
async deleteDepartment(projectId: number, department: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`)
return response.data
},
async addDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.post(`/projects/${projectId}/departments/${encodedDepartment}/task-types`, { task_type: taskType })
return response.data
},
async renameDepartmentTaskType(projectId: number, department: string, oldTaskType: string, newTaskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const encodedTaskType = encodeURIComponent(oldTaskType)
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`, {
old_name: oldTaskType,
new_name: newTaskType
})
return response.data
},
async removeDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const encodedTaskType = encodeURIComponent(taskType)
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`)
return response.data
}
}
+3 -3
View File
@@ -26,7 +26,7 @@ export interface ProjectMember {
id: number
user_id: number
project_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
department_role?: string
joined_at: string
user_email: string
user_first_name: string
@@ -58,11 +58,11 @@ export interface ProjectUpdate {
export interface ProjectMemberCreate {
user_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
department_role?: string
}
export interface ProjectMemberUpdate {
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
department_role?: string
}
export interface DeliveryMovieSpec {
+2
View File
@@ -11,6 +11,7 @@ export interface Task {
description?: string
task_type: string
status: TaskStatus
department?: string
start_date?: string
deadline?: string
project_id: number
@@ -34,6 +35,7 @@ export interface TaskListItem {
name: string
task_type: string
status: TaskStatus
department?: string
start_date?: string
deadline?: string
project_id: number
+159
View File
@@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { departmentService, type AllDepartmentsResponse, type DepartmentInfo, type DepartmentType } from '@/services/department'
interface ProjectDepartments {
projectId: number
data: AllDepartmentsResponse
lastFetched: number
}
export const useDepartmentsStore = defineStore('departments', () => {
// Cache departments by project ID
const projectDepartments = ref<Map<number, ProjectDepartments>>(new Map())
const loading = ref<Set<number>>(new Set())
const error = ref<string | null>(null)
// In-flight request de-dup: concurrent callers for the same project share one promise
const inFlightRequests = new Map<number, Promise<AllDepartmentsResponse>>()
// Cache duration: 5 minutes
const CACHE_DURATION = 5 * 60 * 1000
// Get cached departments for a project
const getProjectDepartments = computed(() => {
return (projectId: number): AllDepartmentsResponse | null => {
const cached = projectDepartments.value.get(projectId)
if (!cached) return null
// Check if cache is still valid
const now = Date.now()
if (now - cached.lastFetched > CACHE_DURATION) {
// Cache expired, remove it
projectDepartments.value.delete(projectId)
return null
}
return cached.data
}
})
// Check if departments are currently being loaded for a project
const isLoading = computed(() => {
return (projectId: number): boolean => {
return loading.value.has(projectId)
}
})
// Get all department names (standard + custom) for a project
const getAllDepartmentOptions = computed(() => {
return (projectId: number): string[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
return departments.departments.map(d => d.name)
}
})
// Get all departments of a given type (shot or asset) for a project
const getDepartmentsByType = computed(() => {
return (projectId: number, type: DepartmentType): DepartmentInfo[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
return departments.departments.filter(d => d.type === type)
}
})
// Get the task types owned by a specific department for a project
const getDepartmentTaskTypes = computed(() => {
return (projectId: number, departmentName: string): string[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
const department = departments.departments.find(d => d.name === departmentName)
return department?.task_types || []
}
})
// Fetch departments for a project
async function fetchProjectDepartments(projectId: number, force = false): Promise<AllDepartmentsResponse> {
// Return cached data if available and not forced
if (!force) {
const cached = getProjectDepartments.value(projectId)
if (cached) {
return cached
}
}
// Share the in-flight request with any concurrent callers instead of re-fetching
const existing = inFlightRequests.get(projectId)
if (existing) {
return existing
}
loading.value.add(projectId)
error.value = null
const request = (async () => {
try {
const data = await departmentService.getAllDepartments(projectId)
// Cache the result
projectDepartments.value.set(projectId, {
projectId,
data,
lastFetched: Date.now()
})
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch departments'
console.error('Error fetching departments:', err)
throw err
} finally {
loading.value.delete(projectId)
inFlightRequests.delete(projectId)
}
})()
inFlightRequests.set(projectId, request)
return request
}
// Invalidate cache for a project (useful after creating/updating/deleting departments)
function invalidateProject(projectId: number) {
projectDepartments.value.delete(projectId)
}
// Clear all cached data
function clearCache() {
projectDepartments.value.clear()
loading.value.clear()
error.value = null
}
// Update cached departments after a change (to avoid refetch)
function updateProjectDepartments(projectId: number, data: AllDepartmentsResponse) {
projectDepartments.value.set(projectId, {
projectId,
data,
lastFetched: Date.now()
})
}
return {
// State
error,
// Computed
getProjectDepartments,
isLoading,
getAllDepartmentOptions,
getDepartmentsByType,
getDepartmentTaskTypes,
// Actions
fetchProjectDepartments,
invalidateProject,
clearCache,
updateProjectDepartments
}
})
+24 -2
View File
@@ -33,7 +33,7 @@
<!-- Tabbed Interface -->
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
<TabsList class="grid w-full grid-cols-7">
<TabsList class="grid w-full grid-cols-8">
<TabsTrigger value="general">
<Settings class="h-4 w-4 mr-2" />
General
@@ -46,6 +46,10 @@
<Users class="h-4 w-4 mr-2" />
Team
</TabsTrigger>
<TabsTrigger value="departments">
<Building2 class="h-4 w-4 mr-2" />
Departments
</TabsTrigger>
<TabsTrigger value="technical">
<Cog class="h-4 w-4 mr-2" />
Technical
@@ -109,6 +113,16 @@
</div>
</TabsContent>
<!-- Department Management Tab -->
<TabsContent value="departments" class="mt-6">
<div class="bg-card rounded-lg border p-6">
<DepartmentManager
:project-id="projectId"
@updated="handleDepartmentsUpdated"
/>
</div>
</TabsContent>
<!-- Technical Specifications Tab -->
<TabsContent value="technical" class="mt-6">
<div class="bg-card rounded-lg border p-6">
@@ -188,7 +202,7 @@ import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen, UploadCloud
ListChecks, FolderOpen, UploadCloud, Building2
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -204,6 +218,7 @@ import ProjectThumbnailUpload from "@/components/project/ProjectThumbnailUpload.
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
import DepartmentManager from "@/components/settings/DepartmentManager.vue";
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
@@ -390,6 +405,13 @@ const handleTaskStatusesUpdated = async () => {
});
};
const handleDepartmentsUpdated = () => {
toast({
title: 'Departments updated',
description: 'Department changes have been saved successfully.'
});
};
const handleTaskTypesUpdated = async () => {
// Refresh task types in the task templates editor
if (taskTemplatesEditorRef.value) {