Compare commits

...

10 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
indigo 172e05af3e Redesign shot detail panel notes: aggregated composer, filters, submission notes
Extracts a new ShotNotes.vue component (mirroring TaskNotes.vue) with a
pinned bottom composer, multi-select task filter, newest/oldest sort,
and toggleable client-only / submission-notes filters. Notes now merge
production notes with each task's submission notes into one
chronological, absolute-timestamped (y/m/d H:M:S) list.

Required restructuring ShotDetailPanel's outer layout to the same
bounded flex-column pattern TaskDetailPanel already uses, since a
properly pinned composer needs a min-h-0 flex chain that the old
single-scroll wrapper didn't provide.

NoteItem.vue gains optional dateFormat and hideClientBadge props
(both default to prior behavior, so TaskDetailPanel is unaffected),
compacts Edit/Delete to icon-only, and the Client badge is now
orange/white instead of a plain outline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:11:32 +08:00
34 changed files with 3419 additions and 559 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 # Models package
from .user import User, UserRole, DepartmentRole from .user import User, UserRole
from .project import Project, ProjectMember, ProjectStatus from .project import Project, ProjectMember, ProjectStatus
from .episode import Episode, EpisodeStatus from .episode import Episode, EpisodeStatus
from .asset import Asset, AssetCategory, AssetStatus from .asset import Asset, AssetCategory, AssetStatus
@@ -17,7 +17,7 @@ from .role import Role, Permission, role_permissions, user_roles
__all__ = [ __all__ = [
# User models # User models
"User", "UserRole", "DepartmentRole", "User", "UserRole",
# Project models # Project models
"Project", "ProjectMember", "ProjectStatus", "Project", "ProjectMember", "ProjectStatus",
# Episode models # 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.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from database import Base from database import Base
from .user import DepartmentRole
import enum import enum
@@ -54,6 +53,9 @@ class Project(Base):
# Custom task statuses # Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project 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 configuration per task type
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag 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) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.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()) joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
+1
View File
@@ -56,6 +56,7 @@ class Task(Base):
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
description = Column(Text) description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses 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) start_date = Column(Date)
deadline = Column(Date) deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
-10
View File
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
DEVELOPER = "developer" DEVELOPER = "developer"
class DepartmentRole(str, enum.Enum):
LAYOUT = "layout"
ANIMATION = "animation"
LIGHTING = "lighting"
COMPOSITE = "composite"
MODELING = "modeling"
RIGGING = "rigging"
SURFACING = "surfacing"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
+14 -10
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict from typing import List, Dict, Optional
from database import get_db from database import get_db
from models.asset import Asset, AssetCategory from models.asset import Asset, AssetCategory
@@ -10,6 +10,7 @@ from models.user import User, UserRole
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
from schemas.task import TaskCreate from schemas.task import TaskCreate
from utils.auth import get_current_user_from_token, require_permission from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.asset_soft_deletion import AssetSoftDeletionService from services.asset_soft_deletion import AssetSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -119,14 +120,14 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_ASSET_TASK_TYPES + custom_types return STANDARD_ASSET_TASK_TYPES + custom_types
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]: def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
"""Create default tasks for an asset.""" """Create default tasks for an asset."""
created_tasks = [] created_tasks = []
for task_type in task_types: for task_type in task_types:
# Create task name based on type # Create task name based on type
task_name = f"{asset.name} - {task_type.title()}" task_name = f"{asset.name} - {task_type.title()}"
# Create the task # Create the task
db_task = Task( db_task = Task(
project_id=asset.project_id, project_id=asset.project_id,
@@ -134,12 +135,13 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"Default {task_type} task for {asset.name}", description=f"Default {task_type} task for {asset.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type) if project else None
) )
db.add(db_task) db.add(db_task)
created_tasks.append(db_task) created_tasks.append(db_task)
return created_tasks return created_tasks
@@ -362,7 +364,7 @@ async def create_asset(
): ):
"""Create a new asset in a project with optional default tasks""" """Create a new asset in a project with optional default tasks"""
# Check project access # Check project access
check_project_access(project_id, current_user, db) project = check_project_access(project_id, current_user, db)
# Check if asset name already exists in project (exclude soft deleted) # Check if asset name already exists in project (exclude soft deleted)
existing_asset = db.query(Asset).filter( existing_asset = db.query(Asset).filter(
@@ -408,7 +410,7 @@ async def create_asset(
task_types = get_default_asset_task_types(asset.category) task_types = get_default_asset_task_types(asset.category)
# Create the tasks # Create the tasks
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db) created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
task_count = len(created_tasks) task_count = len(created_tasks)
db.commit() db.commit()
@@ -553,13 +555,15 @@ async def create_asset_task(
# Create the task # Create the task
task_name = f"{asset.name} - {task_type.title()}" task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task( db_task = Task(
project_id=asset.project_id, project_id=asset.project_id,
asset_id=asset.id, asset_id=asset.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {asset.name}", description=f"{task_type.title()} task for {asset.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type)
) )
db.add(db_task) db.add(db_task)
+515
View File
@@ -1078,6 +1078,521 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project) 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 # Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig) @router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
+16 -10
View File
@@ -13,6 +13,7 @@ from schemas.shot import (
BulkShotCreate, BulkShotResponse, TaskStatusInfo BulkShotCreate, BulkShotResponse, TaskStatusInfo
) )
from utils.auth import get_current_user_from_token, require_permission from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.shot_soft_deletion import ShotSoftDeletionService from services.shot_soft_deletion import ShotSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -131,26 +132,27 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_SHOT_TASK_TYPES + custom_types return STANDARD_SHOT_TASK_TYPES + custom_types
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session): def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
"""Create default tasks for a shot.""" """Create default tasks for a shot."""
created_tasks = [] created_tasks = []
for task_type in task_types: for task_type in task_types:
task_name = f"{shot.name}_{task_type}" task_name = f"{shot.name}_{task_type}"
task_description = f"{task_type.title()} task for shot {shot.name}" task_description = f"{task_type.title()} task for shot {shot.name}"
task = Task( task = Task(
project_id=shot.project_id, project_id=shot.project_id,
episode_id=shot.episode_id, episode_id=shot.episode_id,
shot_id=shot.id, shot_id=shot.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=task_description description=task_description,
department=find_owning_department(project, task_type) if project else None
) )
db.add(task) db.add(task)
created_tasks.append(task) created_tasks.append(task)
return created_tasks return created_tasks
@@ -420,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db) all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings) # Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types() default_task_types = get_default_shot_task_types()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db) project = db.query(Project).filter(Project.id == episode.project_id).first()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
db.commit() db.commit()
task_count = len(created_tasks) task_count = len(created_tasks)
@@ -498,7 +501,8 @@ async def create_shots_bulk(
created_shots = [] created_shots = []
total_tasks_created = 0 total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try: try:
# Create all shots - validation already done above # Create all shots - validation already done above
for i, shot_name in enumerate(shot_names_to_create): for i, shot_name in enumerate(shot_names_to_create):
@@ -525,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested # Create default tasks if requested
task_count = 0 task_count = 0
if bulk_shot.create_default_tasks: if bulk_shot.create_default_tasks:
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db) created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
task_count = len(created_tasks) task_count = len(created_tasks)
total_tasks_created += task_count total_tasks_created += task_count
@@ -694,6 +698,7 @@ async def create_shot_task(
# Create the task # Create the task
task_name = f"{shot.name} - {task_type.title()}" task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task( db_task = Task(
project_id=shot.project_id, project_id=shot.project_id,
episode_id=shot.episode_id, episode_id=shot.episode_id,
@@ -701,7 +706,8 @@ async def create_shot_task(
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {shot.name}", description=f"{task_type.title()} task for {shot.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type)
) )
db.add(db_task) db.add(db_task)
+53 -12
View File
@@ -9,7 +9,7 @@ from datetime import datetime
from database import get_db from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType 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.project import Project, ProjectMember
from models.asset import Asset from models.asset import Asset
from models.shot import Shot from models.shot import Shot
@@ -19,12 +19,13 @@ from schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment, TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse, ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
TaskAttachmentCreate, TaskAttachmentResponse, TaskAttachmentCreate, TaskAttachmentResponse,
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
BulkStatusUpdate, BulkAssignment, BulkActionResult 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.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
from utils.notifications import notification_service from utils.notifications import notification_service
from utils.file_handler import file_handler from utils.file_handler import file_handler
from utils.departments import find_owning_department
router = APIRouter() router = APIRouter()
@@ -248,6 +249,7 @@ async def get_tasks(
"name": task.name, "name": task.name,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date, "start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
@@ -330,6 +332,7 @@ async def get_my_tasks(
name=task.name, name=task.name,
task_type=task.task_type, task_type=task.task_type,
status=task.status, status=task.status,
department=task.department,
start_date=task.start_date, start_date=task.start_date,
deadline=task.deadline, deadline=task.deadline,
project_id=task.project_id, project_id=task.project_id,
@@ -413,10 +416,16 @@ async def create_task(
# Validate the provided status # Validate the provided status
if not validate_task_status(db, task.project_id, task_data['status']): if not validate_task_status(db, task.project_id, task_data['status']):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid status '{task_data['status']}' for this project" detail=f"Invalid status '{task_data['status']}' for this project"
) )
# If the task type belongs to a department, that department wins over
# anything explicitly submitted for `department`.
owning_department = find_owning_department(project, task_data.get('task_type'))
if owning_department:
task_data['department'] = owning_department
# Create task # Create task
db_task = Task(**task_data) db_task = Task(**task_data)
db.add(db_task) db.add(db_task)
@@ -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) @router.get("/{task_id}", response_model=TaskResponse)
async def get_task( async def get_task(
task_id: int, task_id: int,
@@ -720,6 +749,7 @@ async def get_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date, "start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
@@ -806,7 +836,15 @@ async def update_task(
status_code=400, status_code=400,
detail=f"Invalid status '{update_data['status']}' for this project" detail=f"Invalid status '{update_data['status']}' for this project"
) )
# If the task type is being changed to one owned by a department, that
# department wins over anything explicitly submitted for `department`.
if 'task_type' in update_data:
project = db.query(Project).filter(Project.id == task.project_id).first()
owning_department = find_owning_department(project, update_data['task_type'])
if owning_department:
update_data['department'] = owning_department
# Update task # Update task
for field, value in update_data.items(): for field, value in update_data.items():
setattr(task, field, value) setattr(task, field, value)
@@ -846,6 +884,7 @@ async def update_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date, "start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
@@ -938,6 +977,7 @@ async def update_task_status(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date, "start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
@@ -1004,13 +1044,13 @@ async def assign_task(
# Check if user's department role matches task type (optional validation) # Check if user's department role matches task type (optional validation)
task_to_department_mapping = { task_to_department_mapping = {
"layout": DepartmentRole.LAYOUT, "layout": "layout",
"animation": DepartmentRole.ANIMATION, "animation": "animation",
"lighting": DepartmentRole.LIGHTING, "lighting": "lighting",
"compositing": DepartmentRole.COMPOSITE, "compositing": "composite",
"modeling": DepartmentRole.MODELING, "modeling": "modeling",
"rigging": DepartmentRole.RIGGING, "rigging": "rigging",
"surfacing": DepartmentRole.SURFACING, "surfacing": "surfacing",
"simulation": None # Simulation can be handled by multiple departments "simulation": None # Simulation can be handled by multiple departments
} }
@@ -1044,6 +1084,7 @@ async def assign_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date, "start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "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 import re
from models.project import ProjectStatus, ProjectType from models.project import ProjectStatus, ProjectType
from models.user import DepartmentRole
# Technical Specifications Schemas # Technical Specifications Schemas
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v 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 specifications per department
DEFAULT_DELIVERY_MOVIE_SPECS = { DEFAULT_DELIVERY_MOVIE_SPECS = {
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v 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): class ProjectCreate(ProjectBase):
pass pass
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v 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): class ProjectMemberBase(BaseModel):
user_id: int user_id: int
department_role: Optional[DepartmentRole] = None department_role: Optional[str] = None
class ProjectMemberCreate(ProjectMemberBase): class ProjectMemberCreate(ProjectMemberBase):
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberUpdate(BaseModel): class ProjectMemberUpdate(BaseModel):
department_role: Optional[DepartmentRole] = None department_role: Optional[str] = None
class ProjectMemberResponse(ProjectMemberBase): class ProjectMemberResponse(ProjectMemberBase):
+12 -1
View File
@@ -4,7 +4,6 @@ from datetime import date, datetime
from enum import Enum from enum import Enum
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
from models.user import DepartmentRole
class TaskBase(BaseModel): class TaskBase(BaseModel):
@@ -14,6 +13,7 @@ class TaskBase(BaseModel):
start_date: Optional[date] = None start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses 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): class TaskCreate(TaskBase):
@@ -31,6 +31,7 @@ class TaskUpdate(BaseModel):
start_date: Optional[date] = None start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses 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 assigned_user_id: Optional[int] = None
@@ -70,6 +71,7 @@ class TaskListResponse(BaseModel):
name: str name: str
task_type: str # Changed from TaskType enum to str to support custom task types 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 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 start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
project_id: int project_id: int
@@ -195,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
from_attributes = True 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 # Review schemas
class ReviewBase(BaseModel): class ReviewBase(BaseModel):
decision: ReviewDecision 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 --> <!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto"> <div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')"> <DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges> <template #badges>
<!-- Deletion status indicator for admins --> <!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0"> <Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
@@ -21,8 +21,8 @@
</DetailPanelHeader> </DetailPanelHeader>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col"> <Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos" title="Infos"> <TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" /> <Info class="h-4 w-4" />
<span class="sr-only">Infos</span> <span class="sr-only">Infos</span>
@@ -255,7 +255,13 @@
<!-- Notes Tab --> <!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden"> <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> </TabsContent>
<!-- References Tab --> <!-- References Tab -->
@@ -280,13 +286,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue' import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue' import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.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 AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset' 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 { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task { interface Task {
id: number id: number
@@ -315,10 +322,12 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore() const authStore = useAuthStore()
const userStore = useUserStore() const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state // Reactive state
const asset = ref<Asset | null>(null) const asset = ref<Asset | null>(null)
const notes = ref<any[]>([]) const notes = ref<ProductionNote[]>([])
const submissions = ref<Submission[]>([])
const references = ref<any[]>([]) const references = ref<any[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -356,9 +365,15 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100) 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 availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type)) 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(() => { const taskStatusCounts = computed(() => {
@@ -387,7 +402,8 @@ const loadAssetDetails = async () => {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
asset.value = await assetService.getAsset(props.assetId) asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution) // Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) { if (userStore.users.length === 0) {
try { try {
@@ -405,28 +421,18 @@ const loadAssetDetails = async () => {
} }
const loadNotes = async () => { const loadNotes = async () => {
if (tasks.value.length === 0) {
notes.value = []
submissions.value = []
return
}
try { try {
// Load notes from all tasks associated with this asset const [notesByTask, submissionsByTask] = await Promise.all([
const { taskService } = await import('@/services/task') Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
const allNotes: any[] = [] Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
for (const task of tasks.value) { notes.value = notesByTask.flat()
if (task.id) { submissions.value = submissionsByTask.flat()
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()
)
} catch (err) { } catch (err) {
console.error('Failed to load notes:', 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"> <Breadcrumb class="flex-1">
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index"> <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 }} {{ crumb.label }}
</BreadcrumbLink> </BreadcrumbLink>
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }"> <BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
{{ crumb.label }} {{ crumb.label }}
</BreadcrumbPage> </BreadcrumbPage>
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" /> <BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -76,7 +116,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -97,10 +137,11 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb' } 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb' import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue' import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
import NotificationCenter from './NotificationCenter.vue' import NotificationCenter from './NotificationCenter.vue'
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
// Generate breadcrumbs based on current route with enhanced context // Generate breadcrumbs based on current route with enhanced context
const breadcrumbs = ref<BreadcrumbData[]>([]) 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 () => { const updateBreadcrumbs = async () => {
try { try {
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route) breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
@@ -104,7 +104,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle, 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' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench, rigging: Wrench,
surfacing: Paintbrush 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) => { const getFrameRateLabel = (frameRate: number) => {
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user' import { userService } from '@/services/user'
import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
interface Props { interface Props {
@@ -248,16 +249,14 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl() const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = [ const departmentRoles = computed(() => {
{ value: 'layout', label: 'Layout' }, return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
{ value: 'animation', label: 'Animation' }, value: department,
{ value: 'lighting', label: 'Lighting' }, label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
{ value: 'composite', label: 'Composite' }, }))
{ value: 'modeling', label: 'Modeling' }, })
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
// State // State
const members = ref<ProjectMember[]>([]) const members = ref<ProjectMember[]>([])
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
loadMembers() loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
}) })
</script> </script>
+288 -117
View File
@@ -20,6 +20,19 @@
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }} {{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button> </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"> <div class="flex items-center gap-1 border rounded-md p-0.5">
<Button <Button
v-for="scale in SCALES" 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"> <div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }} {{ error }}
</div> </div>
<div v-else class="flex-1 overflow-auto"> <div v-else class="flex-1 flex flex-col overflow-hidden">
<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-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 }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart. {{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div> </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. No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
</div> </div>
<div v-else class="relative min-w-max"> <div v-else class="flex-1 flex overflow-hidden">
<!-- Date axis header: month row on top, date-number row below --> <!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
<div class="flex sticky top-0 z-30 bg-background border-b"> <div
<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"> ref="frozenPaneRef"
Task Type / Task 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>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<div class="relative h-5 border-b"> <div v-for="group in rowGroups" :key="group.key">
<div <div
v-for="marker in topAxisMarkers" class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
:key="'month-' + marker.left" :style="{ height: GROUP_ROW_HEIGHT + 'px' }"
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" @click="toggleGroup(group.key)"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }" >
> <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' }">
{{ marker.label }} <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>
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
</div> </div>
<div class="relative h-6">
<template v-if="!isCollapsed(group.key)">
<div <div
v-for="marker in axisMarkers" v-for="task in group.tasks"
:key="'day-' + marker.left" :key="task.id"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap" class="group flex items-center border-b hover:bg-muted/30"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }" :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>
</div> </template>
</div> </div>
</div> </div>
<!-- Weekend shading --> <!-- Timeline pane: date axis + bars, scrolls both ways -->
<div <div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
v-for="col in weekendColumns" <div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
:key="col.left" <!-- Date axis header: month row on top, date-number row below -->
class="absolute top-0 bottom-0 pointer-events-none" <div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }" <div class="relative h-5 border-b">
></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' }">
<div <div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]" v-for="marker in topAxisMarkers"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }" :key="'month-' + marker.left"
:style="taskBarStyle(task)" 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"
:title="taskBarTitle(task)" :style="{ left: marker.left + 'px', width: marker.width + 'px' }"
> >
<div {{ marker.label }}
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing" </div>
style="right: 6px;" </div>
@mousedown="startDrag($event, task, 'move')" <div class="relative h-6">
></div> <div
<div v-for="marker in axisMarkers"
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l" :key="'day-' + marker.left"
style="background-color: rgba(0, 0, 0, 0.2);" class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
@mousedown="startDrag($event, task, 'resize-start')" :style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
></div> >
<div {{ marker.label }}
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>
</div> </div>
</div> </div>
</div> </div>
</template>
</div>
<!-- Today marker --> <!-- Weekend shading -->
<div <div
v-if="todayLeft !== null" v-for="col in weekendColumns"
class="absolute top-0 bottom-0 w-px pointer-events-none" :key="col.left"
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }" class="absolute top-0 bottom-0 pointer-events-none"
> :style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap"> ></div>
Today
</span> <!-- 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> </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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue' import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.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 { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel' import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -236,10 +304,37 @@ const {
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([]) const tasks = ref<TaskListItem[]>([])
const submissionDates = ref<SubmissionDateInfo[]>([])
const episodeFilter = ref<number | null>(null) const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set()) const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56 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 const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number] 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 episodeOptions = computed(() => {
const map = new Map<number, string>() const map = new Map<number, string>()
for (const t of tasks.value) { for (const t of tasks.value) {
@@ -433,23 +555,43 @@ const todayLeft = computed(() => {
return dateToLeft(todayUtc) return dateToLeft(todayUtc)
}) })
interface TaskTypeGroup { const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
taskType: string 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[] tasks: TaskListItem[]
barLeft: number | null barLeft: number | null
barWidth: number | null barWidth: number | null
} }
const taskTypeGroups = computed<TaskTypeGroup[]>(() => { const rowGroups = computed<RowGroup[]>(() => {
const byType = new Map<string, TaskListItem[]>() const byKey = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) { for (const t of scheduledTasks.value) {
if (!byType.has(t.task_type)) byType.set(t.task_type, []) const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
byType.get(t.task_type)!.push(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])) .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 || '')) const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null let barLeft: number | null = null
let barWidth: number | null = null let barWidth: number | null = null
@@ -465,18 +607,19 @@ const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
barLeft = dateToLeft(minStart!) barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4) 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 { function isCollapsed(key: string): boolean {
return collapsedGroups.value.has(taskType) return collapsedGroups.value.has(key)
} }
function toggleGroup(taskType: string) { function toggleGroup(key: string) {
const next = new Set(collapsedGroups.value) const next = new Set(collapsedGroups.value)
if (next.has(taskType)) next.delete(taskType) if (next.has(key)) next.delete(key)
else next.add(taskType) else next.add(key)
collapsedGroups.value = next collapsedGroups.value = next
} }
@@ -485,9 +628,13 @@ const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() { function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value collapsedGroups.value = hasCollapsedGroups.value
? new Set() ? 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 { function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status) const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8' return status?.color || '#94A3B8'
@@ -497,6 +644,16 @@ function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id 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 --- // --- Drag to reschedule ---
interface DragState { interface DragState {
@@ -620,6 +777,7 @@ function openTask(taskId: number) {
onMounted(() => { onMounted(() => {
loadTasks() loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId) taskStatusesStore.fetchProjectStatuses(props.projectId)
}) })
@@ -633,7 +791,20 @@ watch(() => props.projectId, () => {
collapsedGroups.value = new Set() collapsedGroups.value = new Set()
manualRangeStart.value = '' manualRangeStart.value = ''
manualRangeEnd.value = '' manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks() loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId) taskStatusesStore.fetchProjectStatuses(props.projectId)
}) })
</script> </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"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <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"> <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> </p>
</div> </div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List --> <!-- Asset Task Types List -->
<div class="border rounded-lg divide-y"> <div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0"> <template v-if="assetTaskTypeRows.length > 0">
<div <div
v-for="taskType in assetTaskTypes" v-for="row in assetTaskTypeRows"
:key="taskType" :key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span> <span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary"> <Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
Standard <Badge v-else variant="outline">Custom</Badge>
</Badge> <Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
</div> </div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2"> <div v-if="!row.isStandard" class="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="openEditDialog('asset', taskType)" @click="openEditDialog('asset', row.name, row.department)"
> >
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleDelete('asset', taskType)" @click="handleDelete('asset', row.name, row.department)"
> >
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List --> <!-- Shot Task Types List -->
<div class="border rounded-lg divide-y"> <div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0"> <template v-if="shotTaskTypeRows.length > 0">
<div <div
v-for="taskType in shotTaskTypes" v-for="row in shotTaskTypeRows"
:key="taskType" :key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span> <span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary"> <Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
Standard <Badge v-else variant="outline">Custom</Badge>
</Badge> <Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
</div> </div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2"> <div v-if="!row.isStandard" class="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="openEditDialog('shot', taskType)" @click="openEditDialog('shot', row.name, row.department)"
> >
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleDelete('shot', taskType)" @click="handleDelete('shot', row.name, row.department)"
> >
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -134,9 +128,9 @@
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} {{ dialogCategory === 'asset' ? 'Asset' : 'Shot' }} Task Type {{ dialogMode === 'add' ? 'Add' : 'Edit' }} {{ dialogCategory === 'asset' ? 'Asset' : 'Shot' }} Task Type
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{{ dialogMode === 'add' {{ dialogMode === 'add'
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.' ? '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.' : 'Update the task type name. This will update all existing tasks using this type.'
}} }}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -155,7 +149,25 @@
{{ validationError }} {{ validationError }}
</p> </p>
<p v-else class="text-sm text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@@ -173,14 +185,15 @@
</Dialog> </Dialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<AlertDialog <AlertDialog
:open="isDeleteDialogOpen" :open="isDeleteDialogOpen"
@update:open="(open) => { @update:open="(open) => {
isDeleteDialogOpen = open isDeleteDialogOpen = open
if (!open && !isDeleting.value) { if (!open && !isDeleting.value) {
// Only clear values when dialog closes and we're not in the middle of deleting // Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = '' taskTypeToDelete = ''
categoryToDelete = '' categoryToDelete = ''
departmentToDelete = ''
deleteError = '' deleteError = ''
} }
}" }"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType' 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' import { useToast } from '@/components/ui/toast/use-toast'
interface Props { interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>() }>()
const { toast } = useToast() const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State // State
const isLoading = ref(true) const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false) const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add') const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset') const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('') const taskTypeName = ref('')
const originalTaskTypeName = ref('') const originalTaskTypeName = ref('')
const validationError = ref('') const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false) const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('') const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset') const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('') const deleteError = ref('')
const isDeleting = ref(false) 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 standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_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(() => { const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
}) })
// Methods // Methods
const loadTaskTypes = async () => { const loadTaskTypes = async () => {
try { try {
isLoading.value = true 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) { } catch (error: any) {
console.error('Failed to load task types:', error) console.error('Failed to load task types:', error)
toast({ 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 => { const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ') return taskType.replace(/_/g, ' ')
} }
const validateTaskTypeName = () => { const validateTaskTypeName = () => {
const name = taskTypeName.value.trim() const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) { if (name.length === 0) {
validationError.value = '' validationError.value = ''
return return
} }
if (name.length < 3) { if (name.length < minLength) {
validationError.value = 'Task type name must be at least 3 characters' validationError.value = `Task type name must be at least ${minLength} characters`
return return
} }
if (name.length > 50) { if (name.length > 50) {
validationError.value = 'Task type name must be at most 50 characters' validationError.value = 'Task type name must be at most 50 characters'
return return
} }
if (!/^[a-z0-9_]+$/.test(name)) { if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Task type name must be lowercase alphanumeric with underscores only' validationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return 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) { if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingTypes.includes(name)) { if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists' validationError.value = 'A task type with this name already exists'
return return
} }
} }
validationError.value = '' validationError.value = ''
} }
const openAddDialog = (category: 'asset' | 'shot') => { const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add' dialogMode.value = 'add'
dialogCategory.value = category dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = '' taskTypeName.value = ''
originalTaskTypeName.value = '' originalTaskTypeName.value = ''
validationError.value = '' validationError.value = ''
isDialogOpen.value = true isDialogOpen.value = true
} }
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => { const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit' dialogMode.value = 'edit'
dialogCategory.value = category dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType taskTypeName.value = taskType
originalTaskTypeName.value = taskType originalTaskTypeName.value = taskType
validationError.value = '' validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => { const closeDialog = () => {
isDialogOpen.value = false isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = '' taskTypeName.value = ''
originalTaskTypeName.value = '' originalTaskTypeName.value = ''
validationError.value = '' validationError.value = ''
@@ -372,45 +432,58 @@ const closeDialog = () => {
const handleDialogSave = async () => { const handleDialogSave = async () => {
validateTaskTypeName() validateTaskTypeName()
if (!isTaskTypeNameValid.value) { if (!isTaskTypeNameValid.value) {
return return
} }
try { try {
isSaving.value = true isSaving.value = true
if (dialogMode.value === 'add') { if (dialogMode.value === 'add') {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, { if (dialogDepartment.value) {
task_type: taskTypeName.value.trim(), const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
category: dialogCategory.value departmentsStore.updateProjectDepartments(props.projectId, response)
}) } else {
console.log('Add task type response:', response) const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
taskTypes.value = response task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully` description: `Task type "${taskTypeName.value}" added successfully`
}) })
} else { } else {
const response = await customTaskTypeService.updateCustomTaskType( if (editingDepartment.value) {
props.projectId, const response = await departmentService.renameDepartmentTaskType(
originalTaskTypeName.value, props.projectId,
{ editingDepartment.value,
old_name: originalTaskTypeName.value, originalTaskTypeName.value,
new_name: taskTypeName.value.trim(), taskTypeName.value.trim()
category: dialogCategory.value )
} departmentsStore.updateProjectDepartments(props.projectId, response)
) } else {
console.log('Update task type response:', response) const response = await customTaskTypeService.updateCustomTaskType(
taskTypes.value = response props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
description: `Task type updated successfully` description: `Task type updated successfully`
}) })
} }
emit('updated') emit('updated')
closeDialog() closeDialog()
} catch (error: any) { } 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 taskTypeToDelete.value = taskType
categoryToDelete.value = category categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = '' deleteError.value = ''
isDeleteDialogOpen.value = true isDeleteDialogOpen.value = true
} }
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations // Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try { try {
isDeleting.value = true isDeleting.value = true
@@ -454,35 +529,44 @@ const confirmDelete = async () => {
return return
} }
const response = await customTaskTypeService.deleteCustomTaskType( if (departmentToDeleteLocal) {
props.projectId, const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
taskTypeToDeleteLocal, departmentsStore.updateProjectDepartments(props.projectId, response)
categoryToDeleteLocal } else {
) const response = await customTaskTypeService.deleteCustomTaskType(
taskTypes.value = response props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
description: `Task type "${taskTypeToDeleteLocal}" deleted successfully` description: `Task type "${taskTypeToDeleteLocal}" deleted successfully`
}) })
emit('updated') emit('updated')
// Close dialog and clear values // Close dialog and clear values
isDeleteDialogOpen.value = false isDeleteDialogOpen.value = false
taskTypeToDelete.value = '' taskTypeToDelete.value = ''
categoryToDelete.value = '' categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = '' deleteError.value = ''
} catch (error: any) { } catch (error: any) {
console.error('Failed to delete task type:', error) console.error('Failed to delete task type:', error)
const errorData = error.response?.data const errorData = error.response?.data
const detail = errorData?.detail
if (errorData?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type` 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 { } else {
deleteError.value = errorData?.detail || 'Failed to delete task type' deleteError.value = 'Failed to delete task type'
} }
toast({ toast({
title: 'Error', title: 'Error',
description: deleteError.value, 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>
@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col h-full">
<!-- Filter / Sort Toolbar -->
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-1.5">
<Popover v-if="tasks.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
Tasks
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
{{ taskFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-52 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="taskFilters.length === 0"
@update:model-value="taskFilters = []"
>
All Tasks
</CheckableCommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CheckableCommandItem
v-for="task in tasks"
:key="task.id"
:value="String(task.id)"
:model-value="taskFilters.includes(task.id)"
@update:model-value="toggleTaskFilter(task.id)"
>
{{ formatTaskType(task.task_type) }}
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
size="icon-sm"
@click="showSubmissionNotes = !showSubmissionNotes"
>
<Send class="h-3.5 w-3.5" />
<span class="sr-only">Submission notes</span>
</Button>
</TooltipTrigger>
<TooltipContent>Submission notes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showClientOnly ? 'secondary' : 'outline'"
size="icon-sm"
@click="showClientOnly = !showClientOnly"
>
<Megaphone class="h-3.5 w-3.5" />
<span class="sr-only">Client notes only</span>
</Button>
</TooltipTrigger>
<TooltipContent>Client notes only</TooltipContent>
</Tooltip>
</div>
<Select v-model="sortOrder">
<SelectTrigger class="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="combinedEntries.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. Start the conversation below.</p>
</div>
<template v-for="entry in combinedEntries" :key="entry.id">
<!-- Production Note -->
<div v-if="entry.kind === 'note'" class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div>
<NoteItem
:note="entry.note"
:task-id="entry.note.task_id"
date-format="absolute"
hide-client-badge
@note-updated="emit('notesUpdated')"
@reply="handleReply"
/>
</div>
<!-- Submission Note (read-only) -->
<div v-else class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
</div>
<div class="flex gap-3">
<Avatar class="h-8 w-8 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<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 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>
</div>
</div>
</template>
</div>
<!-- Note Input (Bottom) -->
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select task..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
type="button"
size="sm"
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
@click="newNoteType = 'internal'"
>
Internal
</Button>
<Button
type="button"
size="sm"
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
@click="newNoteType = 'client'"
>
Client
</Button>
</div>
<Button
size="sm"
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
@click="handleAddNote"
>
<MessageSquarePlus class="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
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'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import NoteItem from '@/components/task/NoteItem.vue'
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface EntityNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: EntityNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
const { toast } = useToast()
const { isCoordinatorOrAdmin } = usePermission()
const { getAvatarUrl } = useAvatarUrl()
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const taskFilters = ref<number[]>([])
const sortOrder = ref<'newest' | 'oldest'>('newest')
const showSubmissionNotes = ref(true)
const showClientOnly = ref(false)
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const targetTaskId = ref<number | null>(null)
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
watch(() => props.tasks, (tasks) => {
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
targetTaskId.value = tasks[0]?.id ?? null
}
}, { immediate: true })
function toggleTaskFilter(taskId: number) {
const index = taskFilters.value.indexOf(taskId)
if (index > -1) taskFilters.value.splice(index, 1)
else taskFilters.value.push(taskId)
}
function isFilterActive(taskId: number): boolean {
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
}
function taskTypeFor(taskId: number): string {
return props.tasks.find(t => t.id === taskId)?.task_type || ''
}
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
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())}`
}
function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
interface NoteEntry {
kind: 'note'
id: string
date: string
note: ProductionNote
}
interface SubmissionEntry {
kind: 'submission'
id: string
date: string
submission: Submission
}
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
const noteEntries: NoteEntry[] = props.notes
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
? props.submissions
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
: []
const direction = sortOrder.value === 'newest' ? -1 : 1
return [...noteEntries, ...submissionEntries].sort(
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
)
})
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
function handleReply(noteId: number) {
const note = findNote(props.notes, noteId)
if (!note) return
replyToNoteId.value = noteId
targetTaskId.value = note.task_id
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
async function handleAddNote() {
if (!newNoteContent.value.trim() || !targetTaskId.value) return
submitting.value = true
try {
await taskService.createTaskNote(
targetTaskId.value,
newNoteContent.value,
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
title: 'Success',
description: 'Note added successfully'
})
} catch (error: any) {
console.error('Error adding note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to add note',
variant: 'destructive'
})
} finally {
submitting.value = false
}
}
</script>
+46 -116
View File
@@ -10,8 +10,8 @@
/> />
<!-- Shot Details --> <!-- Shot Details -->
<div v-else-if="shot" class="flex-1 overflow-y-auto"> <div v-else-if="shot" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')"> <DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges> <template #badges>
<!-- Deletion status indicator for admins --> <!-- Deletion status indicator for admins -->
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0"> <Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
@@ -21,8 +21,8 @@
</DetailPanelHeader> </DetailPanelHeader>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col"> <Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos" title="Infos"> <TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" /> <Info class="h-4 w-4" />
<span class="sr-only">Infos</span> <span class="sr-only">Infos</span>
@@ -54,7 +54,7 @@
</TabsList> </TabsList>
<!-- Infos Tab --> <!-- Infos Tab -->
<TabsContent value="infos" class="flex-1 p-6 space-y-6"> <TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
<!-- Shot Information --> <!-- Shot Information -->
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold">Shot Information</h3> <h3 class="text-sm font-semibold">Shot Information</h3>
@@ -187,101 +187,45 @@
<p class="text-sm text-muted-foreground">No tasks yet</p> <p class="text-sm text-muted-foreground">No tasks yet</p>
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p> <p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
</div> </div>
<!-- Tasks Table --> <!-- Tasks Cards -->
<div v-else class="border rounded-lg overflow-hidden"> <div v-else class="space-y-1.5">
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b"> <Card
<div>Task Type</div>
<div>Assignee</div>
<div>Status</div>
</div>
<div
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0" class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
@click="$emit('select-task', task, 'infos')" @click="$emit('select-task', task, 'infos')"
> >
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div> <span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> <div class="flex items-center gap-2 flex-shrink-0">
<Avatar class="h-5 w-5 flex-shrink-0" v-if="task.assigned_user_name"> <Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" /> <AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback> <AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
</Avatar> </Avatar>
<span class="truncate">{{ task.assigned_user_name || 'Unassigned' }}</span> <span v-else class="text-xs text-muted-foreground">Unassigned</span>
</div>
<div>
<TaskStatusBadge :status="getTaskStatusObject(task)" compact /> <TaskStatusBadge :status="getTaskStatusObject(task)" compact />
</div> </div>
</div> </Card>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
<!-- Notes Tab --> <!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 p-6 space-y-4"> <TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Production Notes</h3>
<Popover v-if="canCreateNote">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
<Plus class="h-3 w-3 mr-1" />
Add Note
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<Select v-if="tasks.length > 0" v-model="noteTaskFilter">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tasks</SelectItem>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground"> <div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes... Loading notes...
</div> </div>
<EntityNotes
<div v-else-if="filteredShotNotes.length > 0" class="space-y-4"> v-else
<div v-for="note in filteredShotNotes" :key="note.id" class="space-y-1"> :key="shotId"
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeForNote(note) || '') }}</Badge> :tasks="tasks"
<NoteItem :notes="shotNotes"
:note="note" :submissions="shotSubmissions"
:task-id="note.task_id" @notes-updated="loadShotNotes"
@note-updated="handleNoteUpdated" />
@reply="handleNoteReply"
/>
</div>
</div>
<div v-else class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
</div>
</TabsContent> </TabsContent>
<!-- Assets Tab --> <!-- Assets Tab -->
<TabsContent value="assets" class="flex-1 p-6"> <TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3> <h3 class="text-sm font-semibold">Linked Assets</h3>
<Button <Button
@@ -303,7 +247,7 @@
</TabsContent> </TabsContent>
<!-- References Tab --> <!-- References Tab -->
<TabsContent value="references" class="flex-1 p-6"> <TabsContent value="references" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3> <h3 class="text-sm font-semibold">Reference Files</h3>
<Popover v-if="canUploadReferences"> <Popover v-if="canUploadReferences">
@@ -339,7 +283,7 @@
</TabsContent> </TabsContent>
<!-- Design Tab --> <!-- Design Tab -->
<TabsContent value="design" class="flex-1 p-6"> <TabsContent value="design" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3> <h3 class="text-sm font-semibold">Design Information</h3>
<Button <Button
@@ -382,21 +326,22 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue' import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue' import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue' import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import NoteItem from '@/components/task/NoteItem.vue' import EntityNotes from '@/components/shared/EntityNotes.vue'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot' import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote } from '@/services/task' import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl' import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission' import { usePermission } from '@/composables/usePermission'
@@ -431,6 +376,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl() const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission() const { isAdmin, isCoordinatorOrAdmin } = usePermission()
@@ -442,8 +388,8 @@ const error = ref<string | null>(null)
const isCreatingTask = ref(false) const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([]) const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([]) const shotNotes = ref<ProductionNote[]>([])
const shotSubmissions = ref<Submission[]>([])
const isLoadingNotes = ref(false) const isLoadingNotes = ref(false)
const noteTaskFilter = ref<number | 'all'>('all')
// Computed properties // Computed properties
const frameCount = computed(() => { const frameCount = computed(() => {
@@ -483,19 +429,6 @@ const taskStatusCounts = computed(() => {
const canCreateTask = computed(() => isCoordinatorOrAdmin.value) const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const filteredShotNotes = computed(() => {
const notes = noteTaskFilter.value === 'all'
? shotNotes.value
: shotNotes.value.filter(note => note.task_id === noteTaskFilter.value)
return [...notes].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
})
const taskTypeForNote = (note: ProductionNote) => {
return tasks.value.find(task => task.id === note.task_id)?.task_type
}
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value) const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => { const canUploadReferences = computed(() => {
@@ -504,9 +437,15 @@ const canUploadReferences = computed(() => {
const canEditDesign = computed(() => isCoordinatorOrAdmin.value) 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 availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type)) 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 // Methods
@@ -515,6 +454,7 @@ const loadShotDetails = async () => {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId) shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([ await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId), taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers() loadProjectMembers()
@@ -532,14 +472,17 @@ const loadShotDetails = async () => {
const loadShotNotes = async () => { const loadShotNotes = async () => {
if (tasks.value.length === 0) { if (tasks.value.length === 0) {
shotNotes.value = [] shotNotes.value = []
shotSubmissions.value = []
return return
} }
try { try {
isLoadingNotes.value = true isLoadingNotes.value = true
const notesByTask = await Promise.all( const [notesByTask, submissionsByTask] = await Promise.all([
tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => [])) Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
) Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
shotNotes.value = notesByTask.flat() shotNotes.value = notesByTask.flat()
shotSubmissions.value = submissionsByTask.flat()
} catch (err) { } catch (err) {
console.error('Failed to load shot notes:', err) console.error('Failed to load shot notes:', err)
} finally { } finally {
@@ -547,18 +490,6 @@ const loadShotNotes = async () => {
} }
} }
const handleNoteUpdated = () => {
loadShotNotes()
}
const handleNoteReply = (noteId: number) => {
const note = shotNotes.value.find(n => n.id === noteId)
const task = note ? tasks.value.find(t => t.id === note.task_id) : undefined
if (task) {
emit('select-task', task, 'notes', noteId)
}
}
const loadProjectMembers = async () => { const loadProjectMembers = async () => {
try { try {
projectMembers.value = await projectService.getProjectMembers(props.projectId) projectMembers.value = await projectService.getProjectMembers(props.projectId)
@@ -647,7 +578,6 @@ const formatDeletedDate = (deletedAt: string) => {
// Watchers // Watchers
watch(() => props.shotId, (newShotId) => { watch(() => props.shotId, (newShotId) => {
if (newShotId) { if (newShotId) {
noteTaskFilter.value = 'all'
loadShotDetails() loadShotDetails()
} }
}, { immediate: true }) }, { immediate: true })
@@ -1,12 +1,12 @@
<template> <template>
<div class="relative" <div class="relative flex items-center gap-1"
> >
<Select <Select
:model-value="currentStatusId" :model-value="currentStatusId"
@update:model-value="handleStatusChange" @update:model-value="handleStatusChange"
:disabled="isUpdating || isLoadingStatuses" :disabled="isUpdating || isLoadingStatuses"
> >
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs" <SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
:style="{ backgroundColor: currentStatusObject.color }" :style="{ backgroundColor: currentStatusObject.color }"
@@ -19,14 +19,14 @@
</SelectTrigger> </SelectTrigger>
<SelectContent @contextmenu.prevent> <SelectContent @contextmenu.prevent>
<!-- System Statuses --> <!-- System Statuses -->
<SelectItem <SelectItem
v-for="statusOption in allStatusOptions" v-for="statusOption in allStatusOptions"
:key="statusOption.id" :key="statusOption.id"
:value="statusOption.id" :value="statusOption.id"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Color indicator --> <!-- Color indicator -->
<!-- <div <!-- <div
v-if="statusOption.color" v-if="statusOption.color"
class="w-3 h-3 rounded-full border border-border" class="w-3 h-3 rounded-full border border-border"
:style="{ backgroundColor: statusOption.color }" :style="{ backgroundColor: statusOption.color }"
@@ -36,10 +36,116 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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 --> <!-- Loading indicator -->
<div <div
v-if="isUpdating || isLoadingStatuses" v-if="isUpdating || isLoadingStatuses"
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded" 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> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
@@ -56,11 +162,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } 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 TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset' import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task' import { taskService } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus' import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import type { ProjectMember } from '@/services/project'
interface StatusOption { interface StatusOption {
id: string id: string
@@ -73,10 +191,13 @@ interface Props {
taskId: number taskId: number
status: TaskStatus | string status: TaskStatus | string
projectId: number projectId: number
showAssignee?: boolean
assignedUserId?: number | null
} }
interface Emits { interface Emits {
(e: 'status-updated', taskId: number, newStatus: string): void (e: 'status-updated', taskId: number, newStatus: string): void
(e: 'assignment-updated', taskId: number, userId: number | null): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls // Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const { getAvatarUrl } = useAvatarUrl()
const isUpdating = ref(false) 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 // Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId)) const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
+32 -9
View File
@@ -29,10 +29,16 @@
<span class="font-semibold text-sm"> <span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }} {{ note.user_first_name }} {{ note.user_last_name }}
</span> </span>
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge> <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) }} {{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template> <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> </span>
</div> </div>
@@ -68,20 +74,20 @@
<Button <Button
v-if="canEdit" v-if="canEdit"
variant="ghost" variant="ghost"
size="sm" size="icon-sm"
title="Edit"
@click="startEdit" @click="startEdit"
> >
<Pencil class="h-3 w-3 mr-1" /> <Pencil class="h-3.5 w-3.5" />
Edit
</Button> </Button>
<Button <Button
v-if="canDelete" v-if="canDelete"
variant="ghost" variant="ghost"
size="sm" size="icon-sm"
title="Delete"
@click="handleDelete" @click="handleDelete"
> >
<Trash2 class="h-3 w-3 mr-1" /> <Trash2 class="h-3.5 w-3.5" />
Delete
</Button> </Button>
</div> </div>
@@ -92,6 +98,8 @@
:key="childNote.id" :key="childNote.id"
:note="childNote" :note="childNote"
:task-id="taskId" :task-id="taskId"
:date-format="dateFormat"
:hide-client-badge="hideClientBadge"
@note-updated="emit('noteUpdated')" @note-updated="emit('noteUpdated')"
@reply="emit('reply', $event)" @reply="emit('reply', $event)"
/> />
@@ -120,11 +128,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -143,6 +152,8 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
note: ProductionNote note: ProductionNote
taskId: number taskId: number
dateFormat?: 'relative' | 'absolute'
hideClientBadge?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -174,8 +185,20 @@ function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() 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 { function formatDateTime(dateString: string): string {
const date = new Date(dateString) 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())}`
}
const now = new Date() const now = new Date()
const diffMs = now.getTime() - date.getTime() const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000) const diffMins = Math.floor(diffMs / 60000)
@@ -119,11 +119,35 @@
<div class="grid grid-cols-2 gap-4 text-xs"> <div class="grid grid-cols-2 gap-4 text-xs">
<div> <div>
<Label class="text-muted-foreground">Type</Label> <Label class="text-muted-foreground">Type</Label>
<p class="text-sm mt-1"> <div class="mt-1">
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge> <Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
</p> <SelectTrigger class="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="taskType in taskTypeOptions" :key="taskType" :value="taskType">
{{ formatTaskType(taskType) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<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>
<div> <div>
<Label class="text-muted-foreground">Start Date</Label> <Label class="text-muted-foreground">Start Date</Label>
<div class="mt-1"> <div class="mt-1">
@@ -341,6 +365,8 @@ import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue' import TaskSubmissions from './TaskSubmissions.vue'
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task' import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import { useDepartmentsStore } from '@/stores/departments'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -358,6 +384,7 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission() const { isCoordinatorOrAdmin } = usePermission()
const departmentsStore = useDepartmentsStore()
const task = ref<Task | null>(null) const task = ref<Task | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -365,6 +392,9 @@ const error = ref<string | null>(null)
const localStatus = ref('') const localStatus = ref('')
const localStartDate = ref('') const localStartDate = ref('')
const localDeadline = ref('') const localDeadline = ref('')
const localDepartment = ref('')
const flatShotTaskTypes = ref<string[]>([])
const flatAssetTaskTypes = ref<string[]>([])
const notes = ref<ProductionNote[]>([]) const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([]) const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
@@ -392,6 +422,34 @@ const canSubmitWork = computed(() => {
const canReassign = computed(() => isCoordinatorOrAdmin.value) const canReassign = computed(() => isCoordinatorOrAdmin.value)
// Departments are type-scoped (shot vs asset); a standalone task (neither
// shot nor asset) falls back to the unfiltered list.
const departmentOptions = computed(() => {
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() { async function loadTask() {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -400,6 +458,12 @@ async function loadTask() {
localStatus.value = task.value.status localStatus.value = task.value.status
localStartDate.value = task.value.start_date || '' localStartDate.value = task.value.start_date || ''
localDeadline.value = task.value.deadline || '' 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) { } catch (err: any) {
console.error('Error loading task:', err) console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task' error.value = err.response?.data?.detail || 'Failed to load task'
@@ -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') { async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return if (!task.value) return
+14 -6
View File
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
label: string label: string
href?: string href?: string
isActive?: boolean 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 { export class BreadcrumbService {
@@ -33,7 +37,8 @@ export class BreadcrumbService {
// Add project breadcrumb // Add project breadcrumb
crumbs.push({ crumbs.push({
label: project ? project.name : `Project ${projectId}`, label: project ? project.name : `Project ${projectId}`,
href: `/projects/${projectId}` href: `/projects/${projectId}`,
isProjectCrumb: true
}) })
// Handle tab-based navigation // Handle tab-based navigation
@@ -45,7 +50,8 @@ export class BreadcrumbService {
if (tab === 'shots' && route.params.episodeId) { if (tab === 'shots' && route.params.episodeId) {
crumbs.push({ crumbs.push({
label: tabLabel, label: tabLabel,
href: `/projects/${projectId}/shots` href: `/projects/${projectId}/shots`,
isTabCrumb: true
}) })
// Add episode context // Add episode context
@@ -66,11 +72,12 @@ export class BreadcrumbService {
}) })
} }
} }
} else if (pathSegments[2] || tab !== 'overview') { } else {
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to) // Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
crumbs.push({ crumbs.push({
label: tabLabel, label: tabLabel,
isActive: true isActive: true,
isTabCrumb: true
}) })
} }
} }
@@ -85,7 +92,8 @@ export class BreadcrumbService {
if (crumbs.length > 1) { if (crumbs.length > 1) {
crumbs[crumbs.length - 1] = { crumbs[crumbs.length - 1] = {
label: 'Shots', label: 'Shots',
href: `/projects/${projectId}/shots` href: `/projects/${projectId}/shots`,
isTabCrumb: true
} }
} }
crumbs.push({ 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 id: number
user_id: number user_id: number
project_id: number project_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
joined_at: string joined_at: string
user_email: string user_email: string
user_first_name: string user_first_name: string
@@ -58,11 +58,11 @@ export interface ProjectUpdate {
export interface ProjectMemberCreate { export interface ProjectMemberCreate {
user_id: number user_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
} }
export interface ProjectMemberUpdate { export interface ProjectMemberUpdate {
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
} }
export interface DeliveryMovieSpec { export interface DeliveryMovieSpec {
+12
View File
@@ -11,6 +11,7 @@ export interface Task {
description?: string description?: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
department?: string
start_date?: string start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
@@ -34,6 +35,7 @@ export interface TaskListItem {
name: string name: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
department?: string
start_date?: string start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
@@ -109,6 +111,11 @@ export interface Submission {
stream_url?: string stream_url?: string
} }
export interface SubmissionDateInfo {
task_id: number
submitted_at: string
}
export interface TaskFilters { export interface TaskFilters {
projectId?: number projectId?: number
shotId?: number shotId?: number
@@ -249,6 +256,11 @@ class TaskService {
return response.data 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> { async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) 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 --> <!-- Tabbed Interface -->
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full"> <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"> <TabsTrigger value="general">
<Settings class="h-4 w-4 mr-2" /> <Settings class="h-4 w-4 mr-2" />
General General
@@ -46,6 +46,10 @@
<Users class="h-4 w-4 mr-2" /> <Users class="h-4 w-4 mr-2" />
Team Team
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="departments">
<Building2 class="h-4 w-4 mr-2" />
Departments
</TabsTrigger>
<TabsTrigger value="technical"> <TabsTrigger value="technical">
<Cog class="h-4 w-4 mr-2" /> <Cog class="h-4 w-4 mr-2" />
Technical Technical
@@ -109,6 +113,16 @@
</div> </div>
</TabsContent> </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 --> <!-- Technical Specifications Tab -->
<TabsContent value="technical" class="mt-6"> <TabsContent value="technical" class="mt-6">
<div class="bg-card rounded-lg border p-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 { useRoute, useRouter } from "vue-router";
import { import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film, AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen, UploadCloud ListChecks, FolderOpen, UploadCloud, Building2
} from "lucide-vue-next"; } from "lucide-vue-next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -204,6 +218,7 @@ import ProjectThumbnailUpload from "@/components/project/ProjectThumbnailUpload.
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue"; import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue"; import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue"; import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
import DepartmentManager from "@/components/settings/DepartmentManager.vue";
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue"; import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue"; import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.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 () => { const handleTaskTypesUpdated = async () => {
// Refresh task types in the task templates editor // Refresh task types in the task templates editor
if (taskTemplatesEditorRef.value) { if (taskTemplatesEditorRef.value) {