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 @@
+
+
+
+
+
Departments
+
+ Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
+
+
+
+
+
+
+
+
+
+
+
+
All Departments
+
+
+
+
+
+
+
+
+ {{ formatDepartmentName(department) }}
+
+ Standard
+
+
+ Custom
+
+
+
+
+
+
+ No departments defined
+
+
+
+
+
+
+
+
+
{
+ isDeleteDialogOpen = open
+ if (!open && !isDeleting) {
+ departmentToDelete = ''
+ deleteError = ''
+ }
+ }"
+ >
+
+
+ Delete Department
+
+ Are you sure you want to delete the department "{{ departmentToDelete }}"?
+
+ {{ deleteError }}
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
@@ -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) {