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:
@@ -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!")
|
||||||
+14
-10
@@ -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,14 +120,14 @@ 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 = []
|
||||||
|
|
||||||
for task_type in task_types:
|
for task_type in task_types:
|
||||||
# Create task name based on type
|
# Create task name based on type
|
||||||
task_name = f"{asset.name} - {task_type.title()}"
|
task_name = f"{asset.name} - {task_type.title()}"
|
||||||
|
|
||||||
# Create the task
|
# Create the task
|
||||||
db_task = Task(
|
db_task = Task(
|
||||||
project_id=asset.project_id,
|
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,
|
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)
|
||||||
created_tasks.append(db_task)
|
created_tasks.append(db_task)
|
||||||
|
|
||||||
return created_tasks
|
return created_tasks
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+16
-10
@@ -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,26 +132,27 @@ 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 = []
|
||||||
|
|
||||||
for task_type in task_types:
|
for task_type in task_types:
|
||||||
task_name = f"{shot.name}_{task_type}"
|
task_name = f"{shot.name}_{task_type}"
|
||||||
task_description = f"{task_type.title()} task for shot {shot.name}"
|
task_description = f"{task_type.title()} task for shot {shot.name}"
|
||||||
|
|
||||||
task = Task(
|
task = Task(
|
||||||
project_id=shot.project_id,
|
project_id=shot.project_id,
|
||||||
episode_id=shot.episode_id,
|
episode_id=shot.episode_id,
|
||||||
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)
|
||||||
created_tasks.append(task)
|
created_tasks.append(task)
|
||||||
|
|
||||||
return created_tasks
|
return created_tasks
|
||||||
|
|
||||||
|
|
||||||
@@ -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,7 +501,8 @@ 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
|
||||||
for i, shot_name in enumerate(shot_names_to_create):
|
for i, shot_name in enumerate(shot_names_to_create):
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -415,10 +416,16 @@ async def create_task(
|
|||||||
# Validate the provided status
|
# Validate the provided status
|
||||||
if not validate_task_status(db, task.project_id, task_data['status']):
|
if not validate_task_status(db, task.project_id, task_data['status']):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
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)
|
||||||
@@ -829,7 +836,15 @@ async def update_task(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
</div>
|
||||||
</Badge>
|
<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>
|
||||||
<div v-if="!isStandardDepartment(department)" class="flex items-center gap-2">
|
|
||||||
|
<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="openEditDialog(department)"
|
class="h-6 px-2 text-xs"
|
||||||
|
@click="openAddTaskTypeDialog(department.name)"
|
||||||
>
|
>
|
||||||
<Pencil class="h-4 w-4" />
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
</Button>
|
Add Task Type
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
@click="handleDelete(department)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user