From c09710f4e5d45527d884740d7652400aed1303ba Mon Sep 17 00:00:00 2001 From: indigo Date: Wed, 22 Jul 2026 04:03:33 +0800 Subject: [PATCH] Add per-project Department management Departments are now a customizable per-project list (standard + custom), matching the existing custom task type/status pattern, instead of a fixed 7-value enum. They're usable directly on tasks (new field, independent of assignee) and continue to drive team member department roles. --- backend/migrate_project_custom_departments.py | 111 +++++ backend/migrate_task_department.py | 85 ++++ backend/models/__init__.py | 4 +- backend/models/project.py | 6 +- backend/models/task.py | 1 + backend/models/user.py | 10 - backend/routers/projects.py | 258 +++++++++++ backend/routers/tasks.py | 22 +- backend/schemas/department.py | 50 +++ backend/schemas/project.py | 37 +- backend/schemas/task.py | 4 +- .../project/DepartmentSpecsPanel.vue | 5 +- .../project/ProjectMemberManagement.vue | 18 +- .../components/settings/DepartmentManager.vue | 410 ++++++++++++++++++ .../src/components/task/TaskDetailPanel.vue | 55 ++- frontend/src/services/department.ts | 47 ++ frontend/src/services/project.ts | 6 +- frontend/src/services/task.ts | 2 + frontend/src/stores/departments.ts | 138 ++++++ frontend/src/views/ProjectSettingsView.vue | 26 +- 20 files changed, 1220 insertions(+), 75 deletions(-) create mode 100644 backend/migrate_project_custom_departments.py create mode 100644 backend/migrate_task_department.py create mode 100644 backend/schemas/department.py create mode 100644 frontend/src/components/settings/DepartmentManager.vue create mode 100644 frontend/src/services/department.ts create mode 100644 frontend/src/stores/departments.ts diff --git a/backend/migrate_project_custom_departments.py b/backend/migrate_project_custom_departments.py new file mode 100644 index 0000000..b470930 --- /dev/null +++ b/backend/migrate_project_custom_departments.py @@ -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!") diff --git a/backend/migrate_task_department.py b/backend/migrate_task_department.py new file mode 100644 index 0000000..fd1a74a --- /dev/null +++ b/backend/migrate_task_department.py @@ -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!") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 2881687..992c300 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -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 diff --git a/backend/models/project.py b/backend/models/project.py index b302ceb..9fa3a8b 100644 --- a/backend/models/project.py +++ b/backend/models/project.py @@ -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 diff --git a/backend/models/task.py b/backend/models/task.py index 4903bff..54dbfb2 100644 --- a/backend/models/task.py +++ b/backend/models/task.py @@ -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()) diff --git a/backend/models/user.py b/backend/models/user.py index 0fd1292..02bd5b5 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -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" diff --git a/backend/routers/projects.py b/backend/routers/projects.py index e448a25..38cc05e 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1078,6 +1078,264 @@ async def delete_custom_task_type( return _build_all_task_types_response(db_project) +# Department Management Endpoints + +# Standard departments (read-only) +STANDARD_DEPARTMENTS = ["layout", "animation", "lighting", "composite", "modeling", "rigging", "surfacing"] + + +def _build_all_departments_response(db_project: Project): + """Helper function to build AllDepartmentsResponse""" + from schemas.department import AllDepartmentsResponse + + custom_departments = db_project.custom_departments or [] + all_departments = STANDARD_DEPARTMENTS + custom_departments + + return AllDepartmentsResponse( + departments=all_departments, + standard_departments=STANDARD_DEPARTMENTS, + custom_departments=custom_departments + ) + + +@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_DEPARTMENTS: + 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 department_create.department in custom_departments: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Department '{department_create.department}' already exists" + ) + + custom_departments.append(department_create.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 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 [] + + if department_update.old_name not in custom_departments: + 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_DEPARTMENTS: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Department '{department_update.new_name}' is a standard department" + ) + + if department_update.new_name in custom_departments and department_update.new_name != department_update.old_name: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Department '{department_update.new_name}' already exists" + ) + + custom_departments = [ + department_update.new_name if d == department_update.old_name else d + for d in custom_departments + ] + + 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_DEPARTMENTS: + 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 department not in custom_departments: + 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.remove(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) + + # Submission Configuration Endpoints @router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 42ee6df..20b1d95 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -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 @@ -248,6 +248,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 +331,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, @@ -740,6 +742,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, @@ -866,6 +869,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 +962,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 +1029,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 +1069,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, diff --git a/backend/schemas/department.py b/backend/schemas/department.py new file mode 100644 index 0000000..46fd6d2 --- /dev/null +++ b/backend/schemas/department.py @@ -0,0 +1,50 @@ +""" +Pydantic schemas for department management +""" +from pydantic import BaseModel, Field, validator +from typing import List +import re + + +class CustomDepartmentCreate(BaseModel): + """Schema for creating a new custom department""" + department: str = Field(..., min_length=2, max_length=50, description="Department name") + + @validator('department') + def validate_department_name(cls, v): + """Validate department name format""" + if not re.match(r'^[a-z0-9_]{2,50}$', v): + raise ValueError( + 'Department 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(r'^[a-z0-9_]{2,50}$', v): + raise ValueError( + 'Department name must be 2-50 characters, lowercase alphanumeric with underscores only' + ) + return v + + +class AllDepartmentsResponse(BaseModel): + """Schema for response containing all departments (standard + custom)""" + departments: List[str] = Field(..., description="All departments") + standard_departments: List[str] = Field(..., description="Standard departments (read-only)") + custom_departments: List[str] = 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") diff --git a/backend/schemas/project.py b/backend/schemas/project.py index 7e13eb5..69756cf 100644 --- a/backend/schemas/project.py +++ b/backend/schemas/project.py @@ -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): diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 54bab00..ce4c3c9 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -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 diff --git a/frontend/src/components/project/DepartmentSpecsPanel.vue b/frontend/src/components/project/DepartmentSpecsPanel.vue index 4605a57..f33dcae 100644 --- a/frontend/src/components/project/DepartmentSpecsPanel.vue +++ b/frontend/src/components/project/DepartmentSpecsPanel.vue @@ -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) => { diff --git a/frontend/src/components/project/ProjectMemberManagement.vue b/frontend/src/components/project/ProjectMemberManagement.vue index f89fbfb..8fa44b6 100644 --- a/frontend/src/components/project/ProjectMemberManagement.vue +++ b/frontend/src/components/project/ProjectMemberManagement.vue @@ -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([]) @@ -425,5 +424,6 @@ const closeAddDialog = () => { // Lifecycle onMounted(() => { loadMembers() + departmentsStore.fetchProjectDepartments(props.projectId) }) diff --git a/frontend/src/components/settings/DepartmentManager.vue b/frontend/src/components/settings/DepartmentManager.vue new file mode 100644 index 0000000..0e91912 --- /dev/null +++ b/frontend/src/components/settings/DepartmentManager.vue @@ -0,0 +1,410 @@ + + + diff --git a/frontend/src/components/task/TaskDetailPanel.vue b/frontend/src/components/task/TaskDetailPanel.vue index aa6ea1b..d88606f 100644 --- a/frontend/src/components/task/TaskDetailPanel.vue +++ b/frontend/src/components/task/TaskDetailPanel.vue @@ -123,7 +123,22 @@ {{ formatTaskType(task.task_type) }}

-
+
+ +
+ +
+
@@ -341,6 +356,7 @@ 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 { useDepartmentsStore } from '@/stores/departments' import { useAuthStore } from '@/stores/auth' import { useToast } from '@/components/ui/toast/use-toast' @@ -358,6 +374,7 @@ const emit = defineEmits<{ const { toast } = useToast() const authStore = useAuthStore() const { isCoordinatorOrAdmin } = usePermission() +const departmentsStore = useDepartmentsStore() const task = ref(null) const loading = ref(false) @@ -365,6 +382,7 @@ const error = ref(null) const localStatus = ref('') const localStartDate = ref('') const localDeadline = ref('') +const localDepartment = ref('') const notes = ref([]) const attachments = ref([]) const submissions = ref([]) @@ -392,6 +410,15 @@ const canSubmitWork = computed(() => { const canReassign = computed(() => isCoordinatorOrAdmin.value) +const departmentOptions = computed(() => { + if (!task.value) return [] + return departmentsStore.getAllDepartmentOptions(task.value.project_id) +}) + +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 +427,8 @@ 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) } catch (err: any) { console.error('Error loading task:', err) error.value = err.response?.data?.detail || 'Failed to load task' @@ -483,6 +512,30 @@ async function handleDateChange(field: 'start_date' | 'deadline', value: string) } } +async function handleDepartmentChange(value: string) { + if (!task.value) return + + const previous = task.value.department + try { + const updated = await taskService.updateTask(props.taskId, { department: value || null } as any) + task.value.department = updated.department + 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 = previous || '' + toast({ + title: 'Error', + description: error.response?.data?.detail || 'Failed to update task department', + variant: 'destructive' + }) + } +} + async function handleQuickAction(action: 'start' | 'submit') { if (!task.value) return diff --git a/frontend/src/services/department.ts b/frontend/src/services/department.ts new file mode 100644 index 0000000..c844cff --- /dev/null +++ b/frontend/src/services/department.ts @@ -0,0 +1,47 @@ +import { apiClient } from './api' + +export interface AllDepartmentsResponse { + departments: string[] + standard_departments: string[] + custom_departments: string[] +} + +export interface CustomDepartmentCreate { + department: string +} + +export interface CustomDepartmentUpdate { + old_name: string + new_name: string +} + +export interface DepartmentInUseError { + error: string + department: string + member_count: number + task_count: number +} + +export const departmentService = { + async getAllDepartments(projectId: number): Promise { + const response = await apiClient.get(`/projects/${projectId}/departments`) + return response.data + }, + + async addDepartment(projectId: number, data: CustomDepartmentCreate): Promise { + const response = await apiClient.post(`/projects/${projectId}/departments`, data) + return response.data + }, + + async updateDepartment(projectId: number, department: string, data: CustomDepartmentUpdate): Promise { + const encodedDepartment = encodeURIComponent(department) + const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}`, data) + return response.data + }, + + async deleteDepartment(projectId: number, department: string): Promise { + const encodedDepartment = encodeURIComponent(department) + const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`) + return response.data + } +} diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 29bce11..558a209 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -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 { diff --git a/frontend/src/services/task.ts b/frontend/src/services/task.ts index a6cc86b..f778e30 100644 --- a/frontend/src/services/task.ts +++ b/frontend/src/services/task.ts @@ -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 diff --git a/frontend/src/stores/departments.ts b/frontend/src/stores/departments.ts new file mode 100644 index 0000000..7527dbf --- /dev/null +++ b/frontend/src/stores/departments.ts @@ -0,0 +1,138 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { departmentService, type AllDepartmentsResponse } from '@/services/department' + +interface ProjectDepartments { + projectId: number + data: AllDepartmentsResponse + lastFetched: number +} + +export const useDepartmentsStore = defineStore('departments', () => { + // Cache departments by project ID + const projectDepartments = ref>(new Map()) + const loading = ref>(new Set()) + const error = ref(null) + + // In-flight request de-dup: concurrent callers for the same project share one promise + const inFlightRequests = new Map>() + + // 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 options (standard + custom) for a project + const getAllDepartmentOptions = computed(() => { + return (projectId: number): string[] => { + const departments = getProjectDepartments.value(projectId) + if (!departments) return [] + return departments.departments + } + }) + + // Fetch departments for a project + async function fetchProjectDepartments(projectId: number, force = false): Promise { + // 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, + + // Actions + fetchProjectDepartments, + invalidateProject, + clearCache, + updateProjectDepartments + } +}) diff --git a/frontend/src/views/ProjectSettingsView.vue b/frontend/src/views/ProjectSettingsView.vue index 2b8cbeb..63c80dc 100644 --- a/frontend/src/views/ProjectSettingsView.vue +++ b/frontend/src/views/ProjectSettingsView.vue @@ -33,7 +33,7 @@ - + General @@ -46,6 +46,10 @@ Team + + + Departments + Technical @@ -109,6 +113,16 @@
+ + +
+ +
+
+
@@ -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) {