Compare commits

...

9 Commits

Author SHA1 Message Date
indigo 3d67c304a3 Merge department task types into the Task Type management page
Project Settings > Tasks previously only showed the flat custom task
type lists, with department-owned task types manageable only from the
separate Departments tab. The Asset/Shot Task Type sections now show a
merged view (flat + department-owned, tagged with their department),
and the Add/Edit/Delete dialog can create a task type directly under a
custom department instead of just as a general-purpose flat type.
2026-07-24 22:45:25 +08:00
indigo e1dd5c6eae Offer department-owned task types in the shot/asset Add Task dropdowns
ShotDetailPanel and AssetDetailPanel now merge department task types
(filtered to shot/asset departments respectively) into the flat task
type list shown in "Add Task", so types like Animation's blocking or
Composite's first_pass are selectable at creation time instead of only
via editing a task's type afterward. No backend change needed since
task creation already derives department from an owned task type.
2026-07-24 22:45:17 +08:00
indigo 31d17780a4 Add shot/asset typing and owned task types to departments
Departments now carry a type (shot or asset) and own a list of task
types (e.g. Animation: blocking/primary_pass/second_pass, Composite:
first_pass/second_pass, plus a new Simulation department), additive to
the existing flat Custom Task Type system. When a task's type belongs
to a department, its department is derived and kept in sync server-side
across create/update paths; Task Type is now editable in the Task
Detail panel and Department options are filtered to the task's
shot/asset scope.
2026-07-24 08:55:18 +08:00
indigo 9bbd3df53c Add assignee popover to Gantt chart status cells
The Gantt used a status-only EditableTaskStatus, unlike the shot/asset
data tables which pair status with an avatar-popover assignee picker.
Bring the Gantt's task-level EditableTaskStatus to parity (opt-in via a
showAssignee prop so the flat Tasks table's separate assignee column is
unaffected), and widen the frozen status column to fit both controls.
2026-07-22 04:03:42 +08:00
indigo c09710f4e5 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.
2026-07-22 04:03:33 +08:00
indigo 11e1369f2f Add group-by toggle (Task Type / Shot) to the Schedule Gantt chart
Generalizes the previous task-type-only grouping into rowGroups, keyed
by either task_type or the shot/asset name depending on a new toolbar
toggle. Each row's label shows whichever dimension isn't already the
group header, so switching modes stays readable instead of showing
redundant text. Collapse state resets on mode/project change to avoid
stale group keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:34:19 +08:00
indigo 7f260067a2 Add editable status column and submission markers to the Gantt chart
Adds a second frozen "Task Status" column (EditableTaskStatus) next to
the task name, and small white dot markers on each bar showing
submission dates - backed by a new lightweight GET /tasks/submission-dates
endpoint (task_id + submitted_at only, to avoid an N+1 fetch across
potentially hundreds of scheduled tasks).

Restructures the chart's scroll handling from a single scrolling
container with sticky-positioned columns to two independently-scrolled
panes (frozen name/status columns, and the timeline), synced on
vertical scroll. This fixes several issues that came with the sticky
approach - transparency bleed-through on hover and on group-header
rows, row heights not matching between columns, and the horizontal
scrollbar spanning the frozen columns instead of just the timeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:20:28 +08:00
indigo 04c85be0f7 Add tab/project quick-switch dropdowns to the project breadcrumb
The project header breadcrumb now always shows the active tab
(previously Overview was hidden), and both the project-name and
tab-level crumbs become dropdowns: project name lists all projects
(with a checkmark on the active one, plus "All Projects"), and the
tab crumb lists Overview/Shots/Assets/Tasks/Schedule/Settings with a
checkmark on the current tab. The trailing ">" separator is skipped
after any crumb that renders as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:22:35 +08:00
indigo 74be250912 Generalize aggregated notes to a shared component, reuse in Asset panel
Moves shot/ShotNotes.vue to shared/EntityNotes.vue (it had no
shot-specific logic) and wires it into AssetDetailPanel.vue too,
replacing the old read-only AssetNotes.vue. Asset panel gains the same
multi-select task filter, sort, client-only/submission-notes toggles,
and bottom composer that Shot's panel already had, plus the same
min-h-0 layout fix needed for the pinned composer.

NoteItem's absolute-format timestamp now shows date-only (y/m/d) with
an info icon whose tooltip reveals the full y/m/d H:M:S - applied to
both note and submission-note entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:11:07 +08:00
34 changed files with 3002 additions and 445 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Migration script to normalize projects.custom_departments from a flat list of
strings (pre-department-type feature) to a list of objects:
{"name": str, "type": "shot"|"asset", "task_types": [str]}.
Usage:
python migrate_department_task_types.py
"""
import sqlite3
import sys
import json
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def migrate_database():
"""Normalize any legacy plain-string custom_departments entries."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("Projects table not found. Nothing to migrate.")
conn.close()
return
cursor.execute("SELECT id, custom_departments FROM projects")
rows = cursor.fetchall()
normalized_count = 0
for project_id, custom_departments_raw in rows:
if not custom_departments_raw:
continue
try:
custom_departments = json.loads(custom_departments_raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(custom_departments, list) or not custom_departments:
continue
needs_normalization = any(isinstance(d, str) for d in custom_departments)
if not needs_normalization:
continue
normalized = []
for d in custom_departments:
if isinstance(d, str):
print(f" Project {project_id}: normalizing legacy department '{d}' "
f"(defaulting type='shot', task_types=['{d}'] - review if incorrect)")
normalized.append({"name": d, "type": "shot", "task_types": [d]})
else:
normalized.append(d)
cursor.execute(
"UPDATE projects SET custom_departments = ? WHERE id = ?",
(json.dumps(normalized), project_id)
)
normalized_count += 1
conn.commit()
print(f"Migration completed successfully! {normalized_count} project(s) normalized.")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Department Task Types Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
@@ -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!")
+85
View File
@@ -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!")
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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
+1
View File
@@ -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())
-10
View File
@@ -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
View File
@@ -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)
+515
View File
@@ -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
View File
@@ -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)
+53 -12
View File
@@ -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
@@ -19,12 +19,13 @@ from schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
TaskAttachmentCreate, TaskAttachmentResponse,
SubmissionCreate, SubmissionUpdate, SubmissionResponse,
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
BulkStatusUpdate, BulkAssignment, BulkActionResult
)
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)
@@ -682,6 +691,26 @@ async def bulk_assign_tasks(
)
@router.get("/submission-dates", response_model=List[SubmissionDateInfo])
async def get_submission_dates(
project_id: int = Query(..., description="Project ID to fetch submission dates for"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get a lightweight list of (task_id, submitted_at) for every non-deleted
submission on a project's tasks, for rendering submission markers (e.g.
on the Schedule Gantt chart) without fetching full submission payloads.
"""
submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter(
Task.project_id == project_id,
Task.deleted_at.is_(None),
Submission.deleted_at.is_(None)
).all()
return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions]
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: int,
@@ -720,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,
@@ -806,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)
@@ -846,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,
@@ -938,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,
@@ -1004,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
}
@@ -1044,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,
+104
View File
@@ -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")
+2 -35
View File
@@ -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):
+12 -1
View File
@@ -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
@@ -195,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
from_attributes = True
class SubmissionDateInfo(BaseModel):
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
task_id: int
submitted_at: datetime
class Config:
from_attributes = True
# Review schemas
class ReviewBase(BaseModel):
decision: ReviewDecision
+21
View File
@@ -0,0 +1,21 @@
"""Shared helpers for resolving the department that owns a given task type."""
from typing import Optional
def find_owning_department(db_project, task_type: str) -> Optional[str]:
"""Return the name of the department (standard or custom) whose task_types
list contains task_type for this project, or None if no department owns it."""
if not task_type:
return None
from routers.projects import STANDARD_DEPARTMENTS
for department in STANDARD_DEPARTMENTS:
if task_type in department["task_types"]:
return department["name"]
for department in (db_project.custom_departments or []):
if task_type in department.get("task_types", []):
return department["name"]
return None
@@ -10,8 +10,8 @@
/>
<!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
@@ -21,8 +21,8 @@
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
@@ -255,7 +255,13 @@
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
<EntityNotes
:key="assetId"
:tasks="tasks"
:notes="notes"
:submissions="submissions"
@notes-updated="loadNotes"
/>
</TabsContent>
<!-- References Tab -->
@@ -280,13 +286,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import AssetNotes from './AssetNotes.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService } from '@/services/task'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task {
id: number
@@ -315,10 +322,12 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state
const asset = ref<Asset | null>(null)
const notes = ref<any[]>([])
const notes = ref<ProductionNote[]>([])
const submissions = ref<Submission[]>([])
const references = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -356,9 +365,15 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
// Task types offered in "Add Task" include the flat asset task type list
// plus every task type owned by an asset department (e.g. Modeling's own
// task types), deduped against types the asset already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
const taskStatusCounts = computed(() => {
@@ -387,7 +402,8 @@ const loadAssetDetails = async () => {
isLoading.value = true
error.value = null
asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) {
try {
@@ -405,28 +421,18 @@ const loadAssetDetails = async () => {
}
const loadNotes = async () => {
if (tasks.value.length === 0) {
notes.value = []
submissions.value = []
return
}
try {
// Load notes from all tasks associated with this asset
const { taskService } = await import('@/services/task')
const allNotes: any[] = []
for (const task of tasks.value) {
if (task.id) {
const taskNotes = await taskService.getTaskNotes(task.id)
// Add task info to each note for context
const notesWithContext = taskNotes.map(note => ({
...note,
task_name: task.name,
task_type: task.task_type
}))
allNotes.push(...notesWithContext)
}
}
// Sort by date (newest first)
notes.value = allNotes.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
notes.value = notesByTask.flat()
submissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load notes:', err)
}
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-col h-full">
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet for this asset's tasks.</p>
</div>
<div
v-for="note in notes"
:key="note.id"
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">{{ note.author_name }}</span>
<Badge variant="outline" class="text-xs">
{{ formatTaskType(note.task_type) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ note.task_name }} {{ formatDate(note.created_at) }}
</p>
</div>
</div>
<!-- Note Content -->
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MessageSquarePlus } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
const props = defineProps<{
assetId: number
notes: any[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
+81 -4
View File
@@ -8,13 +8,53 @@
<Breadcrumb class="flex-1">
<BreadcrumbList>
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
{{ project.name }}
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<router-link to="/projects">All Projects</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
{{ tabItem.label }}
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
{{ crumb.label }}
</BreadcrumbLink>
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
{{ crumb.label }}
</BreadcrumbPage>
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -76,7 +116,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
@@ -97,10 +137,11 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { User, Settings, LogOut } from 'lucide-vue-next'
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
import NotificationCenter from './NotificationCenter.vue'
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
// Generate breadcrumbs based on current route with enhanced context
const breadcrumbs = ref<BreadcrumbData[]>([])
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
const projectIdParam = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
})
const currentTab = computed(() => route.meta?.tab as string | undefined)
const projectTabItems = [
{ tab: 'overview', label: 'Overview', path: '' },
{ tab: 'shots', label: 'Shots', path: '/shots' },
{ tab: 'assets', label: 'Assets', path: '/assets' },
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
{ tab: 'settings', label: 'Settings', path: '/settings' }
]
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
const projectsStore = useProjectsStore()
const projectsForSwitcher = computed(() => projectsStore.projects)
onMounted(() => {
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
projectsStore.fetchProjects()
}
})
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
// so the trailing ">" separator can be skipped for it.
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
if (crumb.isTabCrumb) return !!projectIdParam.value
return false
}
const updateBreadcrumbs = async () => {
try {
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
@@ -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>
+288 -117
View File
@@ -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"
@@ -67,129 +80,183 @@
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }}
</div>
<div v-else class="flex-1 overflow-auto">
<div v-if="unscheduledCount > 0" class="px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
<div v-else class="flex-1 flex flex-col overflow-hidden">
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="taskTypeGroups.length === 0" class="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>
<div v-else class="relative min-w-max">
<!-- Date axis header: month row on top, date-number row below -->
<div class="flex sticky top-0 z-30 bg-background border-b">
<div class="w-56 h-11 flex-shrink-0 border-r sticky left-0 z-10 bg-background px-3 flex items-center text-xs font-medium text-muted-foreground">
Task Type / Task
<div v-else class="flex-1 flex overflow-hidden">
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
<div
ref="frozenPaneRef"
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
:style="{ width: FROZEN_WIDTH + 'px' }"
@scroll="handleFrozenScroll"
>
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
Task Type / Task
</div>
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
Task Status
</div>
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<div class="relative h-5 border-b">
<div
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
{{ marker.label }}
<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.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.key) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<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>
<div class="relative h-6">
<template v-if="!isCollapsed(group.key)">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
{{ marker.label }}
<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' }">
{{ 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>
</div>
</template>
</div>
</div>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
@click="toggleGroup(group.taskType)"
>
<div class="w-56 flex-shrink-0 border-r px-3 py-2 text-xs font-medium flex items-center gap-1 sticky left-0 z-10 bg-muted/40">
<component
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<div
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
>
<div class="w-56 flex-shrink-0 border-r pl-8 pr-3 py-1.5 text-xs truncate sticky left-0 z-10 bg-background group-hover:bg-muted/30">
{{ task.shot_name || task.asset_name || task.name }}
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<!-- Timeline pane: date axis + bars, scrolls both ways -->
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<!-- Date axis header: month row on top, date-number row below -->
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="relative h-5 border-b">
<div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
:style="taskBarStyle(task)"
:title="taskBarTitle(task)"
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
<div
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
style="right: 6px;"
@mousedown="startDrag($event, task, 'move')"
></div>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-start')"
></div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-end')"
></div>
<span
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 }}
</span>
{{ marker.label }}
</div>
</div>
<div class="relative h-6">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
>
{{ marker.label }}
</div>
</div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<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.key)"
>
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
class="relative border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
:style="taskBarStyle(task)"
:title="taskBarTitle(task)"
>
<div
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
style="right: 6px;"
@mousedown="startDrag($event, task, 'move')"
></div>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-start')"
></div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-end')"
></div>
<span
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'"
>
{{ taskRowLabel(task) }}
</span>
</div>
<!-- Submission date markers -->
<div
v-for="date in submissionDatesFor(task.id)"
:key="date"
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
:title="`Submitted ${formatDate(date)}`"
></div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
</div>
</div>
</div>
</div>
</div>
@@ -213,7 +280,8 @@ import { DatePicker } from '@/components/ui/date-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import { taskService, type TaskListItem } from '@/services/task'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -236,10 +304,37 @@ const {
const isLoading = ref(false)
const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([])
const submissionDates = ref<SubmissionDateInfo[]>([])
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 = 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
const TASK_ROW_HEIGHT = 36
// The frozen (Task Type/Task Status) pane and the timeline pane are two
// independently-scrolled elements so the horizontal scrollbar only ever
// spans the timeline. Vertical scroll position is kept in sync between them.
const frozenPaneRef = ref<HTMLElement | null>(null)
const timelinePaneRef = ref<HTMLElement | null>(null)
let syncingScroll = false
function handleFrozenScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
syncingScroll = false
}
function handleTimelineScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
syncingScroll = false
}
const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number]
@@ -290,6 +385,33 @@ async function loadTasks() {
}
}
async function loadSubmissionDates() {
try {
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
} catch (err) {
console.error('Failed to load submission dates:', err)
}
}
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
const submissionDatesByTask = computed(() => {
const map = new Map<number, string[]>()
for (const s of submissionDates.value) {
const day = s.submitted_at.slice(0, 10)
const existing = map.get(s.task_id)
if (existing) {
if (!existing.includes(day)) existing.push(day)
} else {
map.set(s.task_id, [day])
}
}
return map
})
function submissionDatesFor(taskId: number): string[] {
return submissionDatesByTask.value.get(taskId) || []
}
const episodeOptions = computed(() => {
const map = new Map<number, string>()
for (const t of tasks.value) {
@@ -433,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
@@ -465,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
}
@@ -485,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'
@@ -497,6 +644,16 @@ function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id
}
function handleStatusUpdated(taskId: number, newStatus: string) {
const task = tasks.value.find(t => t.id === taskId)
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 {
@@ -620,6 +777,7 @@ function openTask(taskId: number) {
onMounted(() => {
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
@@ -633,7 +791,20 @@ watch(() => props.projectId, () => {
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
</script>
<style scoped>
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
</style>
@@ -2,9 +2,9 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Custom Task Types</h3>
<h3 class="text-lg font-semibold">Task Types</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom task types beyond the standard types to adapt the pipeline to your project needs
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
</p>
</div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0">
<template v-if="assetTaskTypeRows.length > 0">
<div
v-for="taskType in assetTaskTypes"
:key="taskType"
v-for="row in assetTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('asset', taskType)"
@click="openEditDialog('asset', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('asset', taskType)"
@click="handleDelete('asset', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0">
<template v-if="shotTaskTypeRows.length > 0">
<div
v-for="taskType in shotTaskTypes"
:key="taskType"
v-for="row in shotTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('shot', taskType)"
@click="openEditDialog('shot', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('shot', taskType)"
@click="handleDelete('shot', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -134,9 +128,9 @@
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} {{ dialogCategory === 'asset' ? 'Asset' : 'Shot' }} Task Type
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.'
: 'Update the task type name. This will update all existing tasks using this type.'
{{ dialogMode === 'add'
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.'
: 'Update the task type name. This will update all existing tasks using this type.'
}}
</DialogDescription>
</DialogHeader>
@@ -155,7 +149,25 @@
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
3-50 characters, lowercase alphanumeric with underscores only
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Department (optional)</Label>
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
<SelectTrigger>
<SelectValue placeholder="None (general purpose)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (general purpose)</SelectItem>
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
{{ formatTaskTypeName(department.name) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
</p>
</div>
</div>
@@ -173,14 +185,15 @@
</Dialog>
<!-- Delete Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting.value) {
// Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = ''
categoryToDelete = ''
departmentToDelete = ''
deleteError = ''
}
}"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
import { departmentService, type DepartmentInfo } from '@/services/department'
import { useDepartmentsStore } from '@/stores/departments'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State
const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('')
const originalTaskTypeName = ref('')
const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
const dialogCustomDepartments = computed(() => {
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
})
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
const rows = new Map<string, TaskTypeRow>()
for (const t of flatAll) {
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
}
for (const department of departments) {
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
for (const t of department.task_types) {
const existing = rows.get(t)
if (existing) {
existing.department = department.name
existing.isStandard = existing.isStandard || isDeptStandard
} else {
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
}
}
}
return Array.from(rows.values())
}
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value
const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
})
// Methods
const loadTaskTypes = async () => {
try {
isLoading.value = true
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
const [types] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
departmentsStore.fetchProjectDepartments(props.projectId, true)
])
taskTypes.value = types
} catch (error: any) {
console.error('Failed to load task types:', error)
toast({
@@ -298,65 +361,60 @@ const loadTaskTypes = async () => {
}
}
const isStandardAssetType = (taskType: string): boolean => {
return standardAssetTypes.value.includes(taskType)
}
const isStandardShotType = (taskType: string): boolean => {
return standardShotTypes.value.includes(taskType)
}
const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ')
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 3) {
validationError.value = 'Task type name must be at least 3 characters'
if (name.length < minLength) {
validationError.value = `Task type name must be at least ${minLength} characters`
return
}
if (name.length > 50) {
validationError.value = 'Task type name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
// Check for duplicates (only if adding or changing name)
// Check for duplicates against the merged (flat + department) rows for this category
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
if (existingTypes.includes(name)) {
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists'
return
}
}
validationError.value = ''
}
const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add'
dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit'
dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType
originalTaskTypeName.value = taskType
validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => {
isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
@@ -372,45 +432,58 @@ const closeDialog = () => {
const handleDialogSave = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
console.log('Add task type response:', response)
taskTypes.value = response
if (dialogDepartment.value) {
const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
console.log('Update task type response:', response)
taskTypes.value = response
if (editingDepartment.value) {
const response = await departmentService.renameDepartmentTaskType(
props.projectId,
editingDepartment.value,
originalTaskTypeName.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
@@ -427,9 +500,10 @@ const handleDialogSave = async () => {
}
}
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
taskTypeToDelete.value = taskType
categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = ''
isDeleteDialogOpen.value = true
}
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
@@ -454,35 +529,44 @@ const confirmDelete = async () => {
return
}
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
if (departmentToDeleteLocal) {
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeToDeleteLocal}" deleted successfully`
})
emit('updated')
// Close dialog and clear values
isDeleteDialogOpen.value = false
taskTypeToDelete.value = ''
categoryToDelete.value = ''
categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
const detail = errorData?.detail
if (detail?.task_count) {
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
} else if (typeof detail === 'string') {
deleteError.value = detail
} else {
deleteError.value = errorData?.detail || 'Failed to delete task type'
deleteError.value = 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteError.value,
@@ -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>
@@ -122,7 +122,15 @@
<span class="font-semibold text-sm">
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
</span>
<span class="text-xs text-muted-foreground ml-auto">{{ formatAbsolute(entry.submission.submitted_at) }}</span>
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateOnly(entry.submission.submitted_at) }}
<Tooltip>
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
</div>
@@ -196,7 +204,7 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
@@ -211,13 +219,13 @@ import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface ShotNoteTask {
interface EntityNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: ShotNoteTask[]
tasks: EntityNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
@@ -268,7 +276,13 @@ function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function formatAbsolute(dateString: string): string {
function formatDateOnly(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
@@ -214,7 +214,7 @@
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes...
</div>
<ShotNotes
<EntityNotes
v-else
:key="shotId"
:tasks="tasks"
@@ -335,12 +335,13 @@ import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import ShotNotes from './ShotNotes.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
@@ -375,6 +376,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
@@ -435,9 +437,15 @@ const canUploadReferences = computed(() => {
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
// Task types offered in "Add Task" include the flat shot task type list
// plus every task type owned by a shot department (e.g. Animation's own
// task types), deduped against types the shot already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'shot')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
// Methods
@@ -446,6 +454,7 @@ const loadShotDetails = async () => {
isLoading.value = true
error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers()
@@ -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))
+16 -3
View File
@@ -30,9 +30,15 @@
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
<span class="text-xs text-muted-foreground ml-auto">
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
<Tooltip v-if="dateFormat === 'absolute'">
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(note.created_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
@@ -122,11 +128,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
import { Reply, Pencil, Trash2, Info } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
AlertDialog,
AlertDialogAction,
@@ -178,12 +185,18 @@ function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatDateTime(dateString: string): string {
const date = new Date(dateString)
if (props.dateFormat === 'absolute') {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
const now = new Date()
@@ -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
+14 -6
View File
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
label: string
href?: string
isActive?: boolean
/** True for the crumb representing the current project tab (Overview/Shots/Assets/...), so the header can render a tab-switcher dropdown on it. */
isTabCrumb?: boolean
/** True for the crumb representing the current project name, so the header can render a project-switcher dropdown on it. */
isProjectCrumb?: boolean
}
export class BreadcrumbService {
@@ -33,7 +37,8 @@ export class BreadcrumbService {
// Add project breadcrumb
crumbs.push({
label: project ? project.name : `Project ${projectId}`,
href: `/projects/${projectId}`
href: `/projects/${projectId}`,
isProjectCrumb: true
})
// Handle tab-based navigation
@@ -45,7 +50,8 @@ export class BreadcrumbService {
if (tab === 'shots' && route.params.episodeId) {
crumbs.push({
label: tabLabel,
href: `/projects/${projectId}/shots`
href: `/projects/${projectId}/shots`,
isTabCrumb: true
})
// Add episode context
@@ -66,11 +72,12 @@ export class BreadcrumbService {
})
}
}
} else if (pathSegments[2] || tab !== 'overview') {
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to)
} else {
// Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
crumbs.push({
label: tabLabel,
isActive: true
isActive: true,
isTabCrumb: true
})
}
}
@@ -85,7 +92,8 @@ export class BreadcrumbService {
if (crumbs.length > 1) {
crumbs[crumbs.length - 1] = {
label: 'Shots',
href: `/projects/${projectId}/shots`
href: `/projects/${projectId}/shots`,
isTabCrumb: true
}
}
crumbs.push({
+87
View File
@@ -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
}
}
+3 -3
View File
@@ -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 {
+12
View File
@@ -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
@@ -109,6 +111,11 @@ export interface Submission {
stream_url?: string
}
export interface SubmissionDateInfo {
task_id: number
submitted_at: string
}
export interface TaskFilters {
projectId?: number
shotId?: number
@@ -249,6 +256,11 @@ class TaskService {
return response.data
}
async getSubmissionDates(projectId: number): Promise<SubmissionDateInfo[]> {
const response = await apiClient.get(`/tasks/submission-dates?project_id=${projectId}`)
return response.data
}
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
const formData = new FormData()
formData.append('file', file)
+159
View File
@@ -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
}
})
+24 -2
View File
@@ -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) {