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