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.
This commit is contained in:
2026-07-24 08:55:18 +08:00
parent 9bbd3df53c
commit 31d17780a4
11 changed files with 960 additions and 94 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!")
+10 -6
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict from typing import List, Dict, Optional
from database import get_db from database import get_db
from models.asset import Asset, AssetCategory 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.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
from schemas.task import TaskCreate from schemas.task import TaskCreate
from utils.auth import get_current_user_from_token, require_permission 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 from services.asset_soft_deletion import AssetSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -119,7 +120,7 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_ASSET_TASK_TYPES + custom_types 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.""" """Create default tasks for an asset."""
created_tasks = [] created_tasks = []
@@ -134,7 +135,8 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"Default {task_type} task for {asset.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) db.add(db_task)
@@ -362,7 +364,7 @@ async def create_asset(
): ):
"""Create a new asset in a project with optional default tasks""" """Create a new asset in a project with optional default tasks"""
# Check project access # 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) # Check if asset name already exists in project (exclude soft deleted)
existing_asset = db.query(Asset).filter( existing_asset = db.query(Asset).filter(
@@ -408,7 +410,7 @@ async def create_asset(
task_types = get_default_asset_task_types(asset.category) task_types = get_default_asset_task_types(asset.category)
# Create the tasks # 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) task_count = len(created_tasks)
db.commit() db.commit()
@@ -553,13 +555,15 @@ async def create_asset_task(
# Create the task # Create the task
task_name = f"{asset.name} - {task_type.title()}" task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task( db_task = Task(
project_id=asset.project_id, project_id=asset.project_id,
asset_id=asset.id, asset_id=asset.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {asset.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) db.add(db_task)
+277 -20
View File
@@ -1080,21 +1080,41 @@ async def delete_custom_task_type(
# Department Management Endpoints # Department Management Endpoints
# Standard departments (read-only) # Standard departments (read-only): name, whether they apply to shots or assets,
STANDARD_DEPARTMENTS = ["layout", "animation", "lighting", "composite", "modeling", "rigging", "surfacing"] # 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): def _build_all_departments_response(db_project: Project):
"""Helper function to build AllDepartmentsResponse""" """Helper function to build AllDepartmentsResponse"""
from schemas.department import AllDepartmentsResponse from schemas.department import AllDepartmentsResponse, DepartmentInfo
custom_departments = db_project.custom_departments or [] custom_departments = db_project.custom_departments or []
all_departments = STANDARD_DEPARTMENTS + custom_departments standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
return AllDepartmentsResponse( return AllDepartmentsResponse(
departments=all_departments, departments=standard_infos + custom_infos,
standard_departments=STANDARD_DEPARTMENTS, standard_departments=standard_infos,
custom_departments=custom_departments custom_departments=custom_infos
) )
@@ -1144,19 +1164,23 @@ async def add_department(
custom_departments = db_project.custom_departments or [] custom_departments = db_project.custom_departments or []
if department_create.department in STANDARD_DEPARTMENTS: if department_create.department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom" detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
) )
if department_create.department in custom_departments: if _find_custom_department(custom_departments, department_create.department):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' already exists" detail=f"Department '{department_create.department}' already exists"
) )
custom_departments.append(department_create.department) custom_departments.append({
"name": department_create.department,
"type": department_create.department_type,
"task_types": department_create.task_types
})
db_project.custom_departments = custom_departments db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments') flag_modified(db_project, 'custom_departments')
@@ -1209,29 +1233,28 @@ async def update_department(
) )
custom_departments = db_project.custom_departments or [] custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department_update.old_name)
if department_update.old_name not in custom_departments: if not existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department_update.old_name}' not found" detail=f"Custom department '{department_update.old_name}' not found"
) )
if department_update.new_name in STANDARD_DEPARTMENTS: if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' is a standard department" detail=f"Department '{department_update.new_name}' is a standard department"
) )
if department_update.new_name in custom_departments and department_update.new_name != department_update.old_name: if (department_update.new_name != department_update.old_name
and _find_custom_department(custom_departments, department_update.new_name)):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' already exists" detail=f"Department '{department_update.new_name}' already exists"
) )
custom_departments = [ existing["name"] = department_update.new_name
department_update.new_name if d == department_update.old_name else d
for d in custom_departments
]
db_project.custom_departments = custom_departments db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments') flag_modified(db_project, 'custom_departments')
@@ -1275,7 +1298,7 @@ async def delete_department(
"""Delete a custom department (blocked if any member or task is currently using it)""" """Delete a custom department (blocked if any member or task is currently using it)"""
from models.task import Task from models.task import Task
if department in STANDARD_DEPARTMENTS: if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments cannot be deleted" detail="Standard departments cannot be deleted"
@@ -1291,7 +1314,7 @@ async def delete_department(
custom_departments = db_project.custom_departments or [] custom_departments = db_project.custom_departments or []
if department not in custom_departments: if not _find_custom_department(custom_departments, department):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found" detail=f"Custom department '{department}' not found"
@@ -1318,7 +1341,7 @@ async def delete_department(
} }
) )
custom_departments.remove(department) custom_departments = [d for d in custom_departments if d["name"] != department]
db_project.custom_departments = custom_departments db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments') flag_modified(db_project, 'custom_departments')
@@ -1336,6 +1359,240 @@ async def delete_department(
return _build_all_departments_response(db_project) 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 # Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig) @router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
+11 -5
View File
@@ -13,6 +13,7 @@ from schemas.shot import (
BulkShotCreate, BulkShotResponse, TaskStatusInfo BulkShotCreate, BulkShotResponse, TaskStatusInfo
) )
from utils.auth import get_current_user_from_token, require_permission 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 from services.shot_soft_deletion import ShotSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -131,7 +132,7 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_SHOT_TASK_TYPES + custom_types 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.""" """Create default tasks for a shot."""
created_tasks = [] created_tasks = []
@@ -145,7 +146,8 @@ def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session
shot_id=shot.id, shot_id=shot.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=task_description description=task_description,
department=find_owning_department(project, task_type) if project else None
) )
db.add(task) db.add(task)
@@ -420,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db) all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings) # Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types() 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() db.commit()
task_count = len(created_tasks) task_count = len(created_tasks)
@@ -498,6 +501,7 @@ async def create_shots_bulk(
created_shots = [] created_shots = []
total_tasks_created = 0 total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try: try:
# Create all shots - validation already done above # Create all shots - validation already done above
@@ -525,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested # Create default tasks if requested
task_count = 0 task_count = 0
if bulk_shot.create_default_tasks: 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) task_count = len(created_tasks)
total_tasks_created += task_count total_tasks_created += task_count
@@ -694,6 +698,7 @@ async def create_shot_task(
# Create the task # Create the task
task_name = f"{shot.name} - {task_type.title()}" task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task( db_task = Task(
project_id=shot.project_id, project_id=shot.project_id,
episode_id=shot.episode_id, episode_id=shot.episode_id,
@@ -701,7 +706,8 @@ async def create_shot_task(
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {shot.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) db.add(db_task)
+15
View File
@@ -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.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.notifications import notification_service
from utils.file_handler import file_handler from utils.file_handler import file_handler
from utils.departments import find_owning_department
router = APIRouter() router = APIRouter()
@@ -419,6 +420,12 @@ async def create_task(
detail=f"Invalid status '{task_data['status']}' for this project" 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 # Create task
db_task = Task(**task_data) db_task = Task(**task_data)
db.add(db_task) db.add(db_task)
@@ -830,6 +837,14 @@ async def update_task(
detail=f"Invalid status '{update_data['status']}' for this project" 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 # Update task
for field, value in update_data.items(): for field, value in update_data.items():
setattr(task, field, value) setattr(task, field, value)
+60 -6
View File
@@ -2,23 +2,35 @@
Pydantic schemas for department management Pydantic schemas for department management
""" """
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from typing import List from typing import List, Literal
import re import re
DEPARTMENT_NAME_PATTERN = r'^[a-z0-9_]{2,50}$'
class CustomDepartmentCreate(BaseModel): class CustomDepartmentCreate(BaseModel):
"""Schema for creating a new custom department""" """Schema for creating a new custom department"""
department: str = Field(..., min_length=2, max_length=50, description="Department name") 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') @validator('department')
def validate_department_name(cls, v): def validate_department_name(cls, v):
"""Validate department name format""" """Validate department name format"""
if not re.match(r'^[a-z0-9_]{2,50}$', v): if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError( raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only' 'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
) )
return v 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): class CustomDepartmentUpdate(BaseModel):
"""Schema for updating a custom department name""" """Schema for updating a custom department name"""
@@ -28,18 +40,52 @@ class CustomDepartmentUpdate(BaseModel):
@validator('new_name') @validator('new_name')
def validate_department_name(cls, v): def validate_department_name(cls, v):
"""Validate department name format""" """Validate department name format"""
if not re.match(r'^[a-z0-9_]{2,50}$', v): if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError( raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only' 'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
) )
return v 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): class AllDepartmentsResponse(BaseModel):
"""Schema for response containing all departments (standard + custom)""" """Schema for response containing all departments (standard + custom)"""
departments: List[str] = Field(..., description="All departments") departments: List[DepartmentInfo] = Field(..., description="All departments")
standard_departments: List[str] = Field(..., description="Standard departments (read-only)") standard_departments: List[DepartmentInfo] = Field(..., description="Standard departments (read-only)")
custom_departments: List[str] = Field(..., description="Custom departments") custom_departments: List[DepartmentInfo] = Field(..., description="Custom departments")
class DepartmentInUseError(BaseModel): class DepartmentInUseError(BaseModel):
@@ -48,3 +94,11 @@ class DepartmentInUseError(BaseModel):
department: str = Field(..., description="Department that is in use") department: str = Field(..., description="Department that is in use")
member_count: int = Field(..., description="Number of project members using this department") member_count: int = Field(..., description="Number of project members using this department")
task_count: int = Field(..., description="Number of tasks 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")
+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
@@ -30,32 +30,54 @@
<template v-if="allDepartments.length > 0"> <template v-if="allDepartments.length > 0">
<div <div
v-for="department in allDepartments" v-for="department in allDepartments"
:key="department" :key="department.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" class="p-3 hover:bg-muted/50 transition-colors space-y-2"
> >
<div class="flex items-center gap-3"> <div class="flex items-center justify-between">
<span class="font-medium capitalize">{{ formatDepartmentName(department) }}</span> <div class="flex items-center gap-2">
<Badge v-if="isStandardDepartment(department)" variant="secondary"> <span class="font-medium capitalize">{{ formatName(department.name) }}</span>
Standard <Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
</Badge> <Badge v-else variant="outline">Custom</Badge>
<Badge v-else variant="outline"> <Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
Custom
</Badge>
</div> </div>
<div v-if="!isStandardDepartment(department)" class="flex items-center gap-2"> <div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
<Button <Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
size="sm"
variant="ghost"
@click="openEditDialog(department)"
>
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </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 <Button
v-if="!isStandardDepartment(department.name)"
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleDelete(department)" class="h-6 px-2 text-xs"
@click="openAddTaskTypeDialog(department.name)"
> >
<Trash2 class="h-4 w-4 text-destructive" /> <Plus class="h-3 w-3 mr-1" />
Add Task Type
</Button> </Button>
</div> </div>
</div> </div>
@@ -66,7 +88,7 @@
</div> </div>
</div> </div>
<!-- Add/Edit Dialog --> <!-- Add/Edit Department Dialog -->
<Dialog :open="isDialogOpen" @update:open="closeDialog"> <Dialog :open="isDialogOpen" @update:open="closeDialog">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -98,6 +120,22 @@
2-50 characters, lowercase alphanumeric with underscores only 2-50 characters, lowercase alphanumeric with underscores only
</p> </p>
</div> </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> </div>
<DialogFooter> <DialogFooter>
@@ -112,7 +150,45 @@
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- Delete Confirmation 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 <AlertDialog
:open="isDeleteDialogOpen" :open="isDeleteDialogOpen"
@update:open="(open) => { @update:open="(open) => {
@@ -146,16 +222,52 @@
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Users, Plus, Pencil, Trash2 } from 'lucide-vue-next' import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -174,7 +286,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { useDepartmentsStore } from '@/stores/departments' import { useDepartmentsStore } from '@/stores/departments'
import { departmentService } from '@/services/department' import { departmentService, type DepartmentType } from '@/services/department'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
interface Props { interface Props {
@@ -193,26 +305,45 @@ const departmentsStore = useDepartmentsStore()
// State // State
const isLoading = ref(true) const isLoading = ref(true)
// Dialog state // Department dialog state
const isDialogOpen = ref(false) const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add') const dialogMode = ref<'add' | 'edit'>('add')
const departmentName = ref('') const departmentName = ref('')
const departmentType = ref<DepartmentType | ''>('')
const originalDepartmentName = ref('') const originalDepartmentName = ref('')
const validationError = ref('') const validationError = ref('')
const isSaving = ref(false) const isSaving = ref(false)
// Delete dialog state // 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 isDeleteDialogOpen = ref(false)
const departmentToDelete = ref('') const departmentToDelete = ref('')
const deleteError = ref('') const deleteError = ref('')
const isDeleting = ref(false) 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 // Computed
const allDepartments = computed(() => departmentsStore.getAllDepartmentOptions(props.projectId)) const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []) const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
const isDepartmentNameValid = computed(() => { const isDepartmentNameValid = computed(() => {
return departmentName.value.length >= 2 && !validationError.value return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
})
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
}) })
// Methods // Methods
@@ -233,11 +364,11 @@ const loadDepartments = async () => {
} }
const isStandardDepartment = (department: string): boolean => { const isStandardDepartment = (department: string): boolean => {
return standardDepartments.value.includes(department) return standardDepartments.value.some(d => d.name === department)
} }
const formatDepartmentName = (department: string): string => { const formatName = (name: string): string => {
return department.replace(/_/g, ' ') return name.replace(/_/g, ' ')
} }
const validateDepartmentName = () => { const validateDepartmentName = () => {
@@ -264,7 +395,7 @@ const validateDepartmentName = () => {
} }
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) { if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
if (allDepartments.value.includes(name)) { if (allDepartmentNames.value.includes(name)) {
validationError.value = 'A department with this name already exists' validationError.value = 'A department with this name already exists'
return return
} }
@@ -273,9 +404,37 @@ const validateDepartmentName = () => {
validationError.value = '' 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 = () => { const openAddDialog = () => {
dialogMode.value = 'add' dialogMode.value = 'add'
departmentName.value = '' departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = '' originalDepartmentName.value = ''
validationError.value = '' validationError.value = ''
isDialogOpen.value = true isDialogOpen.value = true
@@ -292,10 +451,25 @@ const openEditDialog = (department: string) => {
const closeDialog = () => { const closeDialog = () => {
isDialogOpen.value = false isDialogOpen.value = false
departmentName.value = '' departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = '' originalDepartmentName.value = ''
validationError.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 () => { const handleDialogSave = async () => {
validateDepartmentName() validateDepartmentName()
@@ -307,7 +481,10 @@ const handleDialogSave = async () => {
isSaving.value = true isSaving.value = true
if (dialogMode.value === 'add') { if (dialogMode.value === 'add') {
const response = await departmentService.addDepartment(props.projectId, { department: departmentName.value.trim() }) const response = await departmentService.addDepartment(props.projectId, {
department: departmentName.value.trim(),
department_type: departmentType.value as DepartmentType
})
departmentsStore.updateProjectDepartments(props.projectId, response) departmentsStore.updateProjectDepartments(props.projectId, response)
toast({ toast({
@@ -347,6 +524,44 @@ const handleDialogSave = async () => {
} }
} }
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) => { const handleDelete = (department: string) => {
departmentToDelete.value = department departmentToDelete.value = department
deleteError.value = '' deleteError.value = ''
@@ -403,6 +618,53 @@ const confirmDelete = async () => {
} }
} }
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 // Lifecycle
onMounted(() => { onMounted(() => {
loadDepartments() loadDepartments()
@@ -119,9 +119,18 @@
<div class="grid grid-cols-2 gap-4 text-xs"> <div class="grid grid-cols-2 gap-4 text-xs">
<div> <div>
<Label class="text-muted-foreground">Type</Label> <Label class="text-muted-foreground">Type</Label>
<p class="text-sm mt-1"> <div class="mt-1">
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge> <Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
</p> <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>
<div> <div>
<Label class="text-muted-foreground">Department</Label> <Label class="text-muted-foreground">Department</Label>
@@ -356,6 +365,7 @@ import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue' import TaskSubmissions from './TaskSubmissions.vue'
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task' import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import { useDepartmentsStore } from '@/stores/departments' import { useDepartmentsStore } from '@/stores/departments'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -383,6 +393,8 @@ const localStatus = ref('')
const localStartDate = ref('') const localStartDate = ref('')
const localDeadline = ref('') const localDeadline = ref('')
const localDepartment = ref('') const localDepartment = ref('')
const flatShotTaskTypes = ref<string[]>([])
const flatAssetTaskTypes = ref<string[]>([])
const notes = ref<ProductionNote[]>([]) const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([]) const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
@@ -410,11 +422,30 @@ const canSubmitWork = computed(() => {
const canReassign = computed(() => isCoordinatorOrAdmin.value) 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(() => { const departmentOptions = computed(() => {
if (!task.value) return [] 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) 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 { function formatDepartment(department: string): string {
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()) return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
} }
@@ -429,6 +460,10 @@ async function loadTask() {
localDeadline.value = task.value.deadline || '' localDeadline.value = task.value.deadline || ''
localDepartment.value = task.value.department || '' localDepartment.value = task.value.department || ''
departmentsStore.fetchProjectDepartments(task.value.project_id) 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) { } catch (err: any) {
console.error('Error loading task:', err) console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task' error.value = err.response?.data?.detail || 'Failed to load task'
@@ -515,10 +550,24 @@ async function handleDateChange(field: 'start_date' | 'deadline', value: string)
async function handleDepartmentChange(value: string) { async function handleDepartmentChange(value: string) {
if (!task.value) return if (!task.value) return
const previous = task.value.department 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 { try {
const updated = await taskService.updateTask(props.taskId, { department: value || null } as any) const updated = await taskService.updateTask(props.taskId, payload as any)
task.value.department = updated.department task.value.department = updated.department
task.value.task_type = updated.task_type
localDepartment.value = updated.department || '' localDepartment.value = updated.department || ''
emit('taskUpdated') emit('taskUpdated')
toast({ toast({
@@ -527,7 +576,8 @@ async function handleDepartmentChange(value: string) {
}) })
} catch (error: any) { } catch (error: any) {
console.error('Error updating department:', error) console.error('Error updating department:', error)
localDepartment.value = previous || '' localDepartment.value = previousDepartment || ''
task.value.task_type = previousTaskType
toast({ toast({
title: 'Error', title: 'Error',
description: error.response?.data?.detail || 'Failed to update task department', description: error.response?.data?.detail || 'Failed to update task department',
@@ -536,6 +586,35 @@ async function handleDepartmentChange(value: string) {
} }
} }
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') { async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return if (!task.value) return
+43 -3
View File
@@ -1,13 +1,23 @@
import { apiClient } from './api' import { apiClient } from './api'
export type DepartmentType = 'shot' | 'asset'
export interface DepartmentInfo {
name: string
type: DepartmentType
task_types: string[]
}
export interface AllDepartmentsResponse { export interface AllDepartmentsResponse {
departments: string[] departments: DepartmentInfo[]
standard_departments: string[] standard_departments: DepartmentInfo[]
custom_departments: string[] custom_departments: DepartmentInfo[]
} }
export interface CustomDepartmentCreate { export interface CustomDepartmentCreate {
department: string department: string
department_type: DepartmentType
task_types?: string[]
} }
export interface CustomDepartmentUpdate { export interface CustomDepartmentUpdate {
@@ -22,6 +32,13 @@ export interface DepartmentInUseError {
task_count: number task_count: number
} }
export interface DepartmentTaskTypeInUseError {
error: string
department: string
task_type: string
task_count: number
}
export const departmentService = { export const departmentService = {
async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> { async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> {
const response = await apiClient.get(`/projects/${projectId}/departments`) const response = await apiClient.get(`/projects/${projectId}/departments`)
@@ -43,5 +60,28 @@ export const departmentService = {
const encodedDepartment = encodeURIComponent(department) const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`) const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`)
return response.data 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
} }
} }
+24 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { departmentService, type AllDepartmentsResponse } from '@/services/department' import { departmentService, type AllDepartmentsResponse, type DepartmentInfo, type DepartmentType } from '@/services/department'
interface ProjectDepartments { interface ProjectDepartments {
projectId: number projectId: number
@@ -45,12 +45,31 @@ export const useDepartmentsStore = defineStore('departments', () => {
} }
}) })
// Get all department options (standard + custom) for a project // Get all department names (standard + custom) for a project
const getAllDepartmentOptions = computed(() => { const getAllDepartmentOptions = computed(() => {
return (projectId: number): string[] => { return (projectId: number): string[] => {
const departments = getProjectDepartments.value(projectId) const departments = getProjectDepartments.value(projectId)
if (!departments) return [] if (!departments) return []
return departments.departments 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 || []
} }
}) })
@@ -128,6 +147,8 @@ export const useDepartmentsStore = defineStore('departments', () => {
getProjectDepartments, getProjectDepartments,
isLoading, isLoading,
getAllDepartmentOptions, getAllDepartmentOptions,
getDepartmentsByType,
getDepartmentTaskTypes,
// Actions // Actions
fetchProjectDepartments, fetchProjectDepartments,