Compare commits

..

3 Commits

Author SHA1 Message Date
indigo 9c5abf6342 Add example-data script for testing submissions and reviews
Idempotent script mirroring the existing note/schedule seed-data
scripts: seeds Submission + Review rows (with real placeholder files
on disk) for the same sample of Dragon Quest shots, so the
submissions/reviews UI has realistic data to test against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:00:47 +08:00
indigo f547d05478 Add project Schedule page with interactive Gantt chart
Adds a Schedule tab (Kitsu-style production schedule) under each
project: tasks grouped by type with drag-to-reschedule bars, Day/Week/
Month zoom, manual date-range control, weekend shading, a frozen task
column, and a two-tier month/date axis header. Requires a new
start_date field on Task (start_date was previously missing; only
deadline existed) and shadcn DatePicker inputs replace native date
inputs on the Schedule toolbar and TaskDetailPanel's Start Date/
Deadline fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 04:10:43 +08:00
indigo 981808b901 Extend submission config: .mxf/codec/frame-rate checks, regex naming, shot/asset tabs
- Movie spec checks now also cover codec (h264/h265/mjpeg/dnxhd/dnxhr/
  prores/uncompressed/avid/cineform) and frame rate (23.976/24/30/48/60),
  detected in-browser via mediainfo.js container parsing - works even
  for formats the native <video> element can't decode (MXF, ProRes,
  DNxHD). Added .mxf to the supported/allowed format lists.
- Naming pattern supports a raw regex mode (validated by compiling
  server-side at save time) as an alternative to the token pattern,
  and gained three more tokens: {task_name}, {project_name},
  {project_code} (fetched lazily only when referenced).
- Submission Configuration settings page now splits Shot Tasks /
  Asset Tasks into separate tabs instead of one merged list; the
  per-task-type card was extracted into SubmissionTypeConfigCard.vue
  to avoid duplicating it across both tabs.
2026-07-19 19:15:05 +08:00
17 changed files with 1512 additions and 203 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
One-off script to generate example start_date/deadline data on shot tasks,
for testing/demoing the Project Schedule (Gantt) page.
Reuses the same shot-sampling approach as generate_note_example_data.py: the
dev database is heavily populated with disposable load-test fixtures, so this
picks a small, bounded sample of shots (from Dragon Quest) that have the
normal 4-task set, rather than scheduling all ~1400 shot tasks.
For each sampled shot, tasks are scheduled in pipeline order (layout ->
animation -> simulation -> lighting -> compositing, any other task types
appended after) with realistic overlapping durations, staggered around
today's date so the chart's "Today" marker falls inside the visible range.
Idempotent: skips any task that already has a start_date set, so it's safe
to re-run without clobbering manually-edited dates.
"""
from datetime import date, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.shot import Shot
from models.task import Task
from models.project import Project
import logging
import random
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PROJECT_NAME = "Dragon Quest"
SAMPLE_SIZE = 15
RNG_SEED = 42
# Canonical pipeline order used to sequence a shot's tasks; anything not
# listed here keeps its natural (query) order, appended at the end.
PIPELINE_ORDER = ["layout", "animation", "simulation", "lighting", "compositing", "previz"]
def pipeline_index(task_type: str) -> int:
return PIPELINE_ORDER.index(task_type) if task_type in PIPELINE_ORDER else len(PIPELINE_ORDER)
def generate_schedule_example_data():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
rng = random.Random(RNG_SEED)
today = date.today()
try:
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
if not project:
logger.error(f"Project '{PROJECT_NAME}' not found")
return
logger.info(f"Project '{PROJECT_NAME}' (id={project.id})")
# Shots with the normal 4-task set (excludes shots with zero tasks
# and the handful of outliers with extra bulk-test tasks piled on).
shots = db.query(Shot).filter(
Shot.project_id == project.id, Shot.deleted_at.is_(None)
).order_by(Shot.id).all()
candidates = [
s for s in shots
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
]
sample_shots = candidates[:SAMPLE_SIZE]
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
tasks_scheduled = 0
tasks_skipped = 0
for shot in sample_shots:
tasks = db.query(Task).filter(
Task.shot_id == shot.id, Task.deleted_at.is_(None)
).all()
tasks.sort(key=lambda t: pipeline_index(t.task_type))
# Stagger each shot's pipeline start across a window that straddles
# today, so the demo chart shows a mix of past/current/future work.
shot_start = today + timedelta(days=rng.randint(-20, 20))
cursor = shot_start
for task in tasks:
if task.start_date is not None:
tasks_skipped += 1
# Still advance the cursor so later tasks in this shot
# don't bunch up if an earlier one was already scheduled.
cursor = task.deadline or cursor
continue
duration = rng.randint(4, 12)
task.start_date = cursor
task.deadline = cursor + timedelta(days=duration)
tasks_scheduled += 1
# Next task starts a little before this one ends (realistic
# pipeline overlap) rather than strictly back-to-back.
cursor = task.deadline - timedelta(days=rng.randint(0, 4))
db.commit()
logger.info(f"Tasks scheduled: {tasks_scheduled}, skipped (already scheduled): {tasks_skipped}")
except Exception as e:
logger.error(f"Generation failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Generating schedule example data...")
generate_schedule_example_data()
logger.info("Done!")
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
One-off script to generate example Submission (and Review) data for
testing/demoing the work-submission and review flow.
Reuses the same shot-sampling approach as generate_note_example_data.py and
generate_schedule_example_data.py: picks the same bounded sample of Dragon
Quest shots (first 15 candidates with the normal 4-task set) rather than
touching all ~1400 shot tasks, so submissions line up with the tasks that
already have start_date/deadline from the schedule seed data.
Not every sampled task gets a submission (a "not started" task realistically
has none) - about 70% do, with 1-3 versions each. Earlier versions are given
a "retake" review, and the latest version is left pending, approved, or
retake'd, with the task's status updated to match. Each submission gets a
real placeholder file on disk under uploads/submissions/<task_id>/ (a tiny
real image for .jpg/.png so thumbnail generation works, plain bytes for
other formats) so the review UI has something to open instead of a 404.
Idempotent: skips any task that already has a submission, so it's safe to
re-run without creating duplicates.
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.shot import Shot
from models.task import Task, Submission, Review, ReviewDecision
from models.project import Project, ProjectMember
from models.user import User, UserRole
from utils.file_handler import file_handler
from PIL import Image
import io
import logging
import random
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PROJECT_NAME = "Dragon Quest"
SAMPLE_SIZE = 15 # same sample window as generate_schedule_example_data.py
RNG_SEED = 42
SUBMISSION_CHANCE = 0.7 # fraction of sampled tasks that get any submission
# Plausible deliverable formats per task type (all within FileHandler's
# SUPPORTED_FORMATS). Kept short - this is placeholder data, not a real
# pipeline convention.
TASK_TYPE_EXTENSIONS = {
"layout": [".ma", ".mov"],
"animation": [".mov", ".ma"],
"simulation": [".mov", ".ma"],
"lighting": [".exr", ".mov"],
"compositing": [".mov", ".png", ".exr"],
"previz": [".mov"],
}
DEFAULT_EXTENSIONS = [".mov"]
SUBMISSION_NOTES = [
"First pass, ready for review.",
"Addressed previous feedback, please take another look.",
"Still WIP on some details but wanted to get eyes on it early.",
"Final polish pass, should be good to approve.",
None, # some submissions have no note at all
]
RETAKE_FEEDBACK = [
"Good start, but needs another pass before this is ready - see notes on the task.",
"Close, but a few issues need fixing before this can move forward.",
"Not quite there yet, please revise and resubmit.",
]
APPROVE_FEEDBACK = [
"Looks great, approved!",
"Nice work, this is ready to move on.",
"Approved - matches the brief.",
]
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
def make_placeholder_file(task: Task, extension: str, version: int, rng: random.Random) -> tuple[str, int]:
"""Write a small real file to disk and return (relative_path, size)."""
task_dir = file_handler.create_directory_structure(task.id, "submission")
base_name = f"{(task.shot_id and 'shot' or 'asset')}_{task.task_type}_v{version:03d}{extension}"
filename = file_handler.generate_unique_filename(base_name, f"v{version:03d}")
file_path = task_dir / filename
if extension in IMAGE_EXTENSIONS:
# A tiny but real image so thumbnail generation (PIL.Image.open) works.
color = (rng.randint(30, 220), rng.randint(30, 220), rng.randint(30, 220))
img = Image.new("RGB", (64, 64), color)
buf = io.BytesIO()
img.save(buf, format="JPEG" if extension in (".jpg", ".jpeg") else "PNG")
file_path.write_bytes(buf.getvalue())
else:
file_path.write_bytes(f"Placeholder {extension} submission file for testing.\n".encode())
relative_path = file_handler.store_relative_path(str(file_path))
return relative_path, file_path.stat().st_size
def generate_submission_example_data():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
rng = random.Random(RNG_SEED)
now = datetime.now(timezone.utc)
try:
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
if not project:
logger.error(f"Project '{PROJECT_NAME}' not found")
return
member_ids = [m.user_id for m in db.query(ProjectMember).filter(
ProjectMember.project_id == project.id
).all()]
if not member_ids:
logger.error(f"No members found on project '{PROJECT_NAME}'")
return
reviewer_ids = [u.id for u in db.query(User).join(
ProjectMember, ProjectMember.user_id == User.id
).filter(
ProjectMember.project_id == project.id,
User.role.in_([UserRole.DIRECTOR, UserRole.COORDINATOR])
).all()] or member_ids
logger.info(f"Project '{PROJECT_NAME}' (id={project.id}), {len(member_ids)} members, {len(reviewer_ids)} potential reviewers")
# Shots with the normal 4-task set (excludes shots with zero tasks
# and the handful of outliers with extra bulk-test tasks piled on).
shots = db.query(Shot).filter(
Shot.project_id == project.id, Shot.deleted_at.is_(None)
).order_by(Shot.id).all()
candidates = [
s for s in shots
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
]
sample_shots = candidates[:SAMPLE_SIZE]
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
tasks_seen = 0
tasks_skipped = 0
tasks_submitted = 0
submissions_created = 0
reviews_created = 0
for shot in sample_shots:
tasks = db.query(Task).filter(
Task.shot_id == shot.id, Task.deleted_at.is_(None)
).all()
for task in tasks:
tasks_seen += 1
existing = db.query(Submission).filter(Submission.task_id == task.id).first()
if existing:
tasks_skipped += 1
continue
if rng.random() >= SUBMISSION_CHANCE:
continue
extensions = TASK_TYPE_EXTENSIONS.get(task.task_type, DEFAULT_EXTENSIONS)
version_count = rng.choice([1, 1, 2, 3])
submitter_id = task.assigned_user_id or rng.choice(member_ids)
# Spread submissions over the days leading up to the deadline
# (or today, if the task has none) so submitted_at reads as a
# real history rather than everything happening "now".
anchor = (
datetime.combine(task.deadline, datetime.min.time(), tzinfo=timezone.utc)
if task.deadline else now
)
submitted_at = anchor - timedelta(days=version_count * 2)
last_submission = None
last_decision = None
for version in range(1, version_count + 1):
extension = rng.choice(extensions)
relative_path, file_size = make_placeholder_file(task, extension, version, rng)
thumbnail_path = None
if extension in IMAGE_EXTENSIONS:
thumbnail_path = file_handler.create_thumbnail(relative_path)
submission = Submission(
task_id=task.id,
user_id=submitter_id,
file_path=relative_path,
file_name=Path(relative_path).name,
version_number=version,
notes=rng.choice(SUBMISSION_NOTES),
submitted_at=submitted_at
)
db.add(submission)
db.flush() # need submission.id for a Review FK
submissions_created += 1
submitted_at += timedelta(days=2)
is_last_version = version == version_count
if not is_last_version:
# Earlier versions were superseded because they needed work.
review = Review(
submission_id=submission.id,
reviewer_id=rng.choice(reviewer_ids),
decision=ReviewDecision.RETAKE,
feedback=rng.choice(RETAKE_FEEDBACK),
reviewed_at=submission.submitted_at + timedelta(hours=rng.randint(2, 20))
)
db.add(review)
reviews_created += 1
else:
# Latest version: leave some pending review, resolve the rest.
outcome = rng.choice(["pending", "approved", "approved", "retake"])
if outcome != "pending":
decision = ReviewDecision.APPROVED if outcome == "approved" else ReviewDecision.RETAKE
feedback = rng.choice(APPROVE_FEEDBACK if outcome == "approved" else RETAKE_FEEDBACK)
review = Review(
submission_id=submission.id,
reviewer_id=rng.choice(reviewer_ids),
decision=decision,
feedback=feedback,
reviewed_at=submission.submitted_at + timedelta(hours=rng.randint(2, 20))
)
db.add(review)
reviews_created += 1
last_decision = outcome
last_submission = submission
if last_submission:
task.status = {
"approved": "approved",
"retake": "retake",
"pending": "submitted"
}[last_decision]
tasks_submitted += 1
db.commit()
logger.info(f"Tasks seen: {tasks_seen}, skipped (already had a submission): {tasks_skipped}")
logger.info(f"Tasks given submissions: {tasks_submitted}")
logger.info(f"Submissions created: {submissions_created}, reviews created: {reviews_created}")
except Exception as e:
logger.error(f"Generation failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Generating submission example data...")
generate_submission_example_data()
logger.info("Done!")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Migration script to add the start_date column to the tasks table.
Usage:
python migrate_task_start_date.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 start_date 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", "start_date"):
print("Column start_date already exists, skipping...")
else:
print("Adding column: start_date")
cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
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 Start Date Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+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
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())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+6
View File
@@ -248,6 +248,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,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"project_name": task.project.name if task.project else None, "project_name": task.project.name if task.project else None,
@@ -329,6 +330,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,
start_date=task.start_date,
deadline=task.deadline, deadline=task.deadline,
project_id=task.project_id, project_id=task.project_id,
project_name=task.project.name if task.project else "Unknown", project_name=task.project.name if task.project else "Unknown",
@@ -718,6 +720,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,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -843,6 +846,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,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -934,6 +938,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,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -1039,6 +1044,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,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
+17 -6
View File
@@ -7,8 +7,8 @@ import re
from utils.file_handler import file_handler from utils.file_handler import file_handler
# Tokens supported in a naming_pattern: {name} (shot/asset name), {task_type}, {version} # Tokens supported in a token-mode naming_pattern
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|version)\}') NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|task_name|project_name|project_code|version)\}')
NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$') NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
@@ -26,7 +26,12 @@ class SubmissionTypeConfig(BaseModel):
itself, it is never used to inspect an uploaded file server-side. itself, it is never used to inspect an uploaded file server-side.
""" """
allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']") allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']")
naming_pattern: Optional[str] = Field(None, description="Filename pattern using {name}, {task_type}, {version} tokens") naming_pattern_is_regex: bool = Field(False, description="If true, naming_pattern is a raw regular expression instead of a token pattern")
naming_pattern: Optional[str] = Field(
None,
description="Either a token pattern (using {name}, {task_type}, {task_name}, {project_name}, "
"{project_code}, {version}) or, if naming_pattern_is_regex is set, a raw regular expression"
)
check_naming: bool = Field(True, description="Whether the naming pattern is actively checked") check_naming: bool = Field(True, description="Whether the naming pattern is actively checked")
required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)") required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)")
check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate") check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate")
@@ -51,15 +56,21 @@ class SubmissionTypeConfig(BaseModel):
return normalized return normalized
@validator('naming_pattern') @validator('naming_pattern')
def validate_naming_pattern(cls, v): def validate_naming_pattern(cls, v, values):
if v is None or v == '': if v is None or v == '':
return None return None
# Strip out known tokens, then everything left must be safe literal characters if values.get('naming_pattern_is_regex'):
try:
re.compile(v)
except re.error as e:
raise ValueError(f'Invalid regular expression: {e}')
return v
# Token mode: strip out known tokens, then everything left must be safe literal characters
stripped = NAMING_PATTERN_TOKEN_RE.sub('', v) stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped): if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
raise ValueError( raise ValueError(
'Naming pattern may only contain letters, numbers, "_", "-", "." ' 'Naming pattern may only contain letters, numbers, "_", "-", "." '
'and the tokens {name}, {task_type}, {version}' 'and the tokens {name}, {task_type}, {task_name}, {project_name}, {project_code}, {version}'
) )
return v return v
+3
View File
@@ -11,6 +11,7 @@ class TaskBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
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
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
@@ -27,6 +28,7 @@ class TaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
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
assigned_user_id: Optional[int] = None assigned_user_id: Optional[int] = None
@@ -68,6 +70,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
start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
project_id: int project_id: int
project_name: str project_name: str
@@ -182,6 +182,7 @@ import {
Package, Package,
ListTodo, ListTodo,
ShieldCheck, ShieldCheck,
GanttChartSquare,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -221,6 +222,7 @@ const projectTabs = computed(() => {
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` }, { id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` }, { id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
{ id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` }, { id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` },
{ id: 'schedule', label: 'Schedule', icon: GanttChartSquare, route: `/projects/${id}/schedule` },
{ id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` }, { id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` },
] ]
}) })
@@ -233,6 +235,7 @@ const activeProjectTab = computed(() => {
if (path.startsWith(`/projects/${id}/shots`)) return 'shots' if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
if (path.startsWith(`/projects/${id}/assets`)) return 'assets' if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks' if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule'
if (path.startsWith(`/projects/${id}/settings`)) return 'settings' if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
return null return null
}) })
@@ -11,132 +11,36 @@
Loading submission configuration... Loading submission configuration...
</div> </div>
<div v-else class="space-y-4"> <Tabs v-else default-value="shot" class="w-full">
<Card v-for="taskType in taskTypes" :key="taskType"> <TabsList class="grid w-full grid-cols-2">
<CardHeader> <TabsTrigger value="shot">Shot Tasks</TabsTrigger>
<div class="flex items-center justify-between"> <TabsTrigger value="asset">Asset Tasks</TabsTrigger>
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle> </TabsList>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
<Switch
:id="`${taskType}_required`"
:model-value="getConfig(taskType).required"
@update:model-value="(val) => (getConfig(taskType).required = !!val)"
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label class="text-xs">Allowed File Types</Label>
<div class="flex flex-wrap gap-3 mt-1">
<label
v-for="ext in ALLOWED_EXTENSIONS"
:key="ext"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="getConfig(taskType).allowed_extensions.includes(ext)"
@update:model-value="(val) => toggleExtension(taskType, ext, !!val)"
/>
{{ ext }}
</label>
</div>
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
</div>
<div> <TabsContent value="shot" class="space-y-4 mt-4">
<div class="flex items-center justify-between"> <SubmissionTypeConfigCard
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label> v-for="taskType in shotTaskTypes"
<div class="flex items-center gap-2"> :key="taskType"
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label> :task-type="taskType"
<Switch :config="getConfig(taskType)"
:id="`${taskType}_check_naming`" />
:model-value="getConfig(taskType).check_naming" <div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
@update:model-value="(val) => (getConfig(taskType).check_naming = !!val)" No shot task types configured for this project yet.
/> </div>
</div> </TabsContent>
</div>
<Input
:id="`${taskType}_pattern`"
v-model="getConfig(taskType).naming_pattern"
placeholder="e.g. {name}_{task_type}_v{version}"
class="h-8"
/>
<p class="text-xs text-muted-foreground mt-1">
Tokens: <code>{name}</code> (shot/asset name), <code>{task_type}</code>, <code>{version}</code> (auto 3-digit)
</p>
</div>
<div> <TabsContent value="asset" class="space-y-4 mt-4">
<div class="flex items-center justify-between"> <SubmissionTypeConfigCard
<Label class="text-xs">Movie Spec</Label> v-for="taskType in assetTaskTypes"
<div class="flex items-center gap-2"> :key="taskType"
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label> :task-type="taskType"
<Switch :config="getConfig(taskType)"
:id="`${taskType}_check_movie_spec`" />
:model-value="getConfig(taskType).check_movie_spec" <div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
@update:model-value="(val) => (getConfig(taskType).check_movie_spec = !!val)" No asset task types configured for this project yet.
/> </div>
</div> </TabsContent>
</div> </Tabs>
<div v-if="getConfig(taskType).check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
<div>
<Input
v-model="getConfig(taskType).movie_resolution"
placeholder="e.g. 1920x1080"
class="h-8"
/>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_format || undefined"
@update:model-value="(val) => (getConfig(taskType).movie_format = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_codec || undefined"
@update:model-value="(val) => (getConfig(taskType).movie_codec = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Codec" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="getConfig(taskType).movie_frame_rate ? String(getConfig(taskType).movie_frame_rate) : undefined"
@update:model-value="(val) => (getConfig(taskType).movie_frame_rate = val ? Number(val) : null)"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Frame rate" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
</div>
</CardContent>
</Card>
<div v-if="taskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No task types configured for this project yet.
</div>
</div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button :disabled="isLoading || isSaving" @click="onSave"> <Button :disabled="isLoading || isSaving" @click="onSave">
@@ -150,15 +54,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type SubmissionTypeConfig } from '@/services/project' import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType' import { customTaskTypeService } from '@/services/customTaskType'
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
interface Props { interface Props {
projectId: number projectId: number
@@ -167,30 +67,17 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const { toast } = useToast() const { toast } = useToast()
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
const ALLOWED_EXTENSIONS = [
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
'.pdf', '.txt', '.doc', '.docx',
'.zip', '.rar', '.7z',
'.ma', '.usd', '.usda', '.usdc'
]
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
const isLoading = ref(false) const isLoading = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const taskTypes = ref<string[]>([]) const shotTaskTypes = ref<string[]>([])
const assetTaskTypes = ref<string[]>([])
const formState = reactive<Record<string, SubmissionTypeConfig>>({}) const formState = reactive<Record<string, SubmissionTypeConfig>>({})
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
const getConfig = (taskType: string): SubmissionTypeConfig => { const getConfig = (taskType: string): SubmissionTypeConfig => {
if (!formState[taskType]) { if (!formState[taskType]) {
formState[taskType] = { formState[taskType] = {
allowed_extensions: [], allowed_extensions: [],
naming_pattern_is_regex: false,
naming_pattern: '', naming_pattern: '',
check_naming: true, check_naming: true,
required: false, required: false,
@@ -204,15 +91,6 @@ const getConfig = (taskType: string): SubmissionTypeConfig => {
return formState[taskType] return formState[taskType]
} }
const toggleExtension = (taskType: string, ext: string, checked: boolean) => {
const config = getConfig(taskType)
if (checked) {
if (!config.allowed_extensions.includes(ext)) config.allowed_extensions.push(ext)
} else {
config.allowed_extensions = config.allowed_extensions.filter(e => e !== ext)
}
}
const load = async () => { const load = async () => {
try { try {
isLoading.value = true isLoading.value = true
@@ -221,11 +99,13 @@ const load = async () => {
projectService.getProjectSubmissionConfig(props.projectId) projectService.getProjectSubmissionConfig(props.projectId)
]) ])
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types])) shotTaskTypes.value = allTypes.shot_task_types
assetTaskTypes.value = allTypes.asset_task_types
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) { for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
formState[taskType] = { formState[taskType] = {
allowed_extensions: [...cfg.allowed_extensions], allowed_extensions: [...cfg.allowed_extensions],
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
naming_pattern: cfg.naming_pattern || '', naming_pattern: cfg.naming_pattern || '',
check_naming: cfg.check_naming, check_naming: cfg.check_naming,
required: cfg.required, required: cfg.required,
@@ -252,7 +132,7 @@ const onSave = async () => {
try { try {
isSaving.value = true isSaving.value = true
const configByTaskType: Record<string, SubmissionTypeConfig> = {} const configByTaskType: Record<string, SubmissionTypeConfig> = {}
for (const taskType of taskTypes.value) { for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
const config = formState[taskType] const config = formState[taskType]
if (!config) continue if (!config) continue
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
@@ -260,6 +140,7 @@ const onSave = async () => {
if (hasConfig) { if (hasConfig) {
configByTaskType[taskType] = { configByTaskType[taskType] = {
allowed_extensions: config.allowed_extensions, allowed_extensions: config.allowed_extensions,
naming_pattern_is_regex: config.naming_pattern_is_regex,
naming_pattern: config.naming_pattern || null, naming_pattern: config.naming_pattern || null,
check_naming: config.check_naming, check_naming: config.check_naming,
required: config.required, required: config.required,
@@ -0,0 +1,173 @@
<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
<Switch
:id="`${taskType}_required`"
:model-value="config.required"
@update:model-value="(val) => (config.required = !!val)"
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label class="text-xs">Allowed File Types</Label>
<div class="flex flex-wrap gap-3 mt-1">
<label
v-for="ext in ALLOWED_EXTENSIONS"
:key="ext"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="config.allowed_extensions.includes(ext)"
@update:model-value="(val) => toggleExtension(ext, !!val)"
/>
{{ ext }}
</label>
</div>
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
:model-value="config.naming_pattern_is_regex"
@update:model-value="(val) => (config.naming_pattern_is_regex = !!val)"
/>
Regex
</label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_naming`"
:model-value="config.check_naming"
@update:model-value="(val) => (config.check_naming = !!val)"
/>
</div>
</div>
</div>
<Input
:id="`${taskType}_pattern`"
v-model="config.naming_pattern"
:placeholder="config.naming_pattern_is_regex ? 'e.g. ^[A-Z]+\\d{3}_layout_v\\d{3}$' : 'e.g. {project_code}_{name}_{task_type}_v{version}'"
class="h-8 font-mono"
/>
<p v-if="config.naming_pattern_is_regex" class="text-xs text-muted-foreground mt-1">
Matched as a regular expression against the filename (without extension)
</p>
<p v-else class="text-xs text-muted-foreground mt-1">
Tokens: <code>{name}</code> (shot/asset name), <code>{task_name}</code>, <code>{task_type}</code>,
<code>{project_name}</code>, <code>{project_code}</code>, <code>{version}</code> (auto 3-digit)
</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label class="text-xs">Movie Spec</Label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_movie_spec`"
:model-value="config.check_movie_spec"
@update:model-value="(val) => (config.check_movie_spec = !!val)"
/>
</div>
</div>
<div v-if="config.check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
<div>
<Input
v-model="config.movie_resolution"
placeholder="e.g. 1920x1080"
class="h-8"
/>
</div>
<div>
<Select
:model-value="config.movie_format || undefined"
@update:model-value="(val) => (config.movie_format = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_codec || undefined"
@update:model-value="(val) => (config.movie_codec = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Codec" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_frame_rate ? String(config.movie_frame_rate) : undefined"
@update:model-value="(val) => (config.movie_frame_rate = val ? Number(val) : null)"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Frame rate" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { SubmissionTypeConfig } from '@/services/project'
const props = defineProps<{
taskType: string
config: SubmissionTypeConfig
}>()
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
const ALLOWED_EXTENSIONS = [
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
'.pdf', '.txt', '.doc', '.docx',
'.zip', '.rar', '.7z',
'.ma', '.usd', '.usda', '.usdc'
]
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
function toggleExtension(ext: string, checked: boolean) {
if (checked) {
if (!props.config.allowed_extensions.includes(ext)) props.config.allowed_extensions.push(ext)
} else {
props.config.allowed_extensions = props.config.allowed_extensions.filter(e => e !== ext)
}
}
</script>
@@ -0,0 +1,639 @@
<template>
<div class="h-full flex flex-col">
<!-- Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 border-b">
<div class="flex flex-wrap items-center gap-3">
<Select
:model-value="episodeFilter === null ? 'all' : String(episodeFilter)"
@update:model-value="handleEpisodeFilterChange"
>
<SelectTrigger class="w-48 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Episodes</SelectItem>
<SelectItem v-for="ep in episodeOptions" :key="ep.id" :value="String(ep.id)">{{ ep.name }}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-8" @click="toggleAllGroups">
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="scale in SCALES"
:key="scale"
:variant="viewScale === scale ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs capitalize"
@click="viewScale = scale"
>
{{ scale }}
</Button>
</div>
<div class="flex items-center gap-1.5">
<div class="w-36">
<DatePicker v-model="manualRangeStart" placeholder="Start date" />
</div>
<span class="text-xs text-muted-foreground">to</span>
<div class="w-36">
<DatePicker v-model="manualRangeEnd" placeholder="End date" :min="manualRangeStart" />
</div>
<Button
v-if="manualRangeStart || manualRangeEnd"
variant="ghost"
size="sm"
class="h-8 px-2 text-xs"
@click="manualRangeStart = ''; manualRangeEnd = ''"
>
Reset
</Button>
</div>
</div>
<div v-if="legendStatuses.length > 0" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span v-for="status in legendStatuses" :key="status.id" class="flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm flex-shrink-0" :style="{ backgroundColor: status.color }"></span>
{{ status.name }}
</span>
</div>
</div>
<div v-if="isLoading" class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Loading schedule...
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }}
</div>
<div v-else class="flex-1 overflow-auto">
<div v-if="unscheduledCount > 0" class="px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="taskTypeGroups.length === 0" class="p-12 text-center text-sm text-muted-foreground">
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
</div>
<div v-else class="relative min-w-max">
<!-- Date axis header: month row on top, date-number row below -->
<div class="flex sticky top-0 z-30 bg-background border-b">
<div class="w-56 h-11 flex-shrink-0 border-r sticky left-0 z-10 bg-background px-3 flex items-center text-xs font-medium text-muted-foreground">
Task Type / Task
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<div class="relative h-5 border-b">
<div
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
{{ marker.label }}
</div>
</div>
<div class="relative h-6">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
>
{{ marker.label }}
</div>
</div>
</div>
</div>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
@click="toggleGroup(group.taskType)"
>
<div class="w-56 flex-shrink-0 border-r px-3 py-2 text-xs font-medium flex items-center gap-1 sticky left-0 z-10 bg-muted/40">
<component
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<div
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
>
<div class="w-56 flex-shrink-0 border-r pl-8 pr-3 py-1.5 text-xs truncate sticky left-0 z-10 bg-background group-hover:bg-muted/30">
{{ task.shot_name || task.asset_name || task.name }}
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<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'"
>
{{ task.shot_name || task.asset_name || task.name }}
</span>
</div>
</div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
</div>
</div>
</div>
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask.id"
@close="closeDetailPanel"
@task-updated="loadTasks"
/>
</DetailPanelOverlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { DatePicker } from '@/components/ui/date-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import { taskService, type TaskListItem } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
projectId: number
}>()
const taskStatusesStore = useTaskStatusesStore()
const { toast } = useToast()
const {
selectedEntity: selectedTask,
showMobileDetail,
showPanel,
closeDetailPanel,
selectEntity: selectTask
} = useDetailPanel<TaskListItem>({ sessionStorageKey: 'scheduleGantt.detailPanelEnabled' })
const isLoading = ref(false)
const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([])
const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56
const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number]
const viewScale = ref<ViewScale>('week')
const SCALE_PIXELS_PER_DAY: Record<ViewScale, number> = { day: 40, week: 22, month: 6 }
const pixelsPerDay = computed(() => SCALE_PIXELS_PER_DAY[viewScale.value])
const manualRangeStart = ref('')
const manualRangeEnd = ref('')
function parseDate(dateStr: string): Date {
return new Date(`${dateStr}T00:00:00Z`)
}
function toDateString(d: Date): string {
return d.toISOString().slice(0, 10)
}
function addDays(d: Date, days: number): Date {
const copy = new Date(d)
copy.setUTCDate(copy.getUTCDate() + days)
return copy
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86400000)
}
function formatTaskType(taskType: string): string {
return taskType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '?'
return parseDate(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
}
async function loadTasks() {
try {
isLoading.value = true
error.value = null
tasks.value = await taskService.getTasks({ projectId: props.projectId, limit: 1000 })
} catch (err: any) {
console.error('Failed to load schedule tasks:', err)
error.value = err.response?.data?.detail || 'Failed to load schedule'
} finally {
isLoading.value = false
}
}
const episodeOptions = computed(() => {
const map = new Map<number, string>()
for (const t of tasks.value) {
if (t.episode_id && t.episode_name && !map.has(t.episode_id)) {
map.set(t.episode_id, t.episode_name)
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
})
function handleEpisodeFilterChange(value: unknown) {
episodeFilter.value = value === 'all' ? null : Number(value)
}
const filteredTasks = computed(() => {
if (episodeFilter.value === null) return tasks.value
return tasks.value.filter(t => t.episode_id === episodeFilter.value)
})
const scheduledTasks = computed(() => filteredTasks.value.filter(t => t.start_date && t.deadline))
const unscheduledCount = computed(() => filteredTasks.value.length - scheduledTasks.value.length)
const autoWindowStart = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let min: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.start_date!)
if (!min || d < min) min = d
}
if (!min) return null
const padded = new Date(min)
padded.setUTCDate(padded.getUTCDate() - 3)
return padded
})
const autoWindowEnd = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let max: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.deadline!)
if (!max || d > max) max = d
}
if (!max) return null
const padded = new Date(max)
padded.setUTCDate(padded.getUTCDate() + 3)
return padded
})
const windowStart = computed<Date | null>(() => manualRangeStart.value ? parseDate(manualRangeStart.value) : autoWindowStart.value)
const windowEnd = computed<Date | null>(() => manualRangeEnd.value ? parseDate(manualRangeEnd.value) : autoWindowEnd.value)
const chartWidth = computed(() => {
if (!windowStart.value || !windowEnd.value) return 0
return Math.max(daysBetween(windowStart.value, windowEnd.value) * pixelsPerDay.value, 400)
})
function dateToLeft(date: Date): number {
if (!windowStart.value) return 0
return daysBetween(windowStart.value, date) * pixelsPerDay.value
}
// One bounded, window-clipped cell per calendar month touching [windowStart, windowEnd].
function computeMonthSegments(labelOptions: Intl.DateTimeFormatOptions): { left: number; width: number; label: string }[] {
if (!windowStart.value || !windowEnd.value) return []
const segments: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), windowStart.value.getUTCMonth(), 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextMonth = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextMonth), rightBound) - left, 2)
segments.push({ left, width, label: cursor.toLocaleDateString('en-US', labelOptions) })
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
}
return segments
}
const axisMarkers = computed<{ left: number; width?: number; label: string }[]>(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
return computeMonthSegments({ month: 'short', timeZone: 'UTC' })
}
const markers: { left: number; label: string }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
markers.push({
left: dateToLeft(cursor),
label: String(cursor.getUTCDate())
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return markers
})
// Coarser grouping row shown above axisMarkers: month spans (day/week scale) or year spans (month scale).
const topAxisMarkers = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
const markers: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), 0, 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextYear = new Date(Date.UTC(cursor.getUTCFullYear() + 1, 0, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextYear), rightBound) - left, 2)
markers.push({ left, width, label: String(cursor.getUTCFullYear()) })
cursor.setUTCFullYear(cursor.getUTCFullYear() + 1)
}
return markers
}
return computeMonthSegments({ month: 'long', year: 'numeric', timeZone: 'UTC' })
})
const weekendColumns = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
const columns: { left: number }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
const day = cursor.getUTCDay()
if (day === 0 || day === 6) {
columns.push({ left: dateToLeft(cursor) })
}
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return columns
})
const todayLeft = computed(() => {
if (!windowStart.value || !windowEnd.value) return null
const now = new Date()
const todayUtc = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
if (todayUtc < windowStart.value || todayUtc > windowEnd.value) return null
return dateToLeft(todayUtc)
})
interface TaskTypeGroup {
taskType: string
tasks: TaskListItem[]
barLeft: number | null
barWidth: number | null
}
const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
const byType = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) {
if (!byType.has(t.task_type)) byType.set(t.task_type, [])
byType.get(t.task_type)!.push(t)
}
return Array.from(byType.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([taskType, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
if (windowStart.value) {
let minStart: Date | null = null
let maxEnd: Date | null = null
for (const t of sorted) {
const s = parseDate(t.start_date!)
const e = parseDate(t.deadline!)
if (!minStart || s < minStart) minStart = s
if (!maxEnd || e > maxEnd) maxEnd = e
}
barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
}
return { taskType, tasks: sorted, barLeft, barWidth }
})
})
function isCollapsed(taskType: string): boolean {
return collapsedGroups.value.has(taskType)
}
function toggleGroup(taskType: string) {
const next = new Set(collapsedGroups.value)
if (next.has(taskType)) next.delete(taskType)
else next.add(taskType)
collapsedGroups.value = next
}
const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? new Set()
: new Set(taskTypeGroups.value.map(g => g.taskType))
}
function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
}
function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id
}
// --- Drag to reschedule ---
interface DragState {
taskId: number
mode: 'move' | 'resize-start' | 'resize-end'
startX: number
originalStart: Date
originalEnd: Date
currentStart: Date
currentEnd: Date
}
const dragState = ref<DragState | null>(null)
function startDrag(event: MouseEvent, task: TaskListItem, mode: DragState['mode']) {
event.preventDefault()
event.stopPropagation()
const originalStart = parseDate(task.start_date!)
const originalEnd = parseDate(task.deadline!)
dragState.value = {
taskId: task.id,
mode,
startX: event.clientX,
originalStart,
originalEnd,
currentStart: originalStart,
currentEnd: originalEnd
}
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragMove(event: MouseEvent) {
const drag = dragState.value
if (!drag) return
const deltaX = event.clientX - drag.startX
const deltaDays = Math.round(deltaX / pixelsPerDay.value)
let newStart = drag.originalStart
let newEnd = drag.originalEnd
if (drag.mode === 'move') {
newStart = addDays(drag.originalStart, deltaDays)
newEnd = addDays(drag.originalEnd, deltaDays)
} else if (drag.mode === 'resize-start') {
newStart = addDays(drag.originalStart, deltaDays)
if (newStart >= drag.originalEnd) newStart = addDays(drag.originalEnd, -1)
} else if (drag.mode === 'resize-end') {
newEnd = addDays(drag.originalEnd, deltaDays)
if (newEnd <= drag.originalStart) newEnd = addDays(drag.originalStart, 1)
}
dragState.value = { ...drag, currentStart: newStart, currentEnd: newEnd }
}
async function handleDragEnd() {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
const drag = dragState.value
dragState.value = null
if (!drag) return
const changed = daysBetween(drag.originalStart, drag.currentStart) !== 0 || daysBetween(drag.originalEnd, drag.currentEnd) !== 0
if (!changed) {
// No movement - treat as a click on the bar body
if (drag.mode === 'move') openTask(drag.taskId)
return
}
const task = tasks.value.find(t => t.id === drag.taskId)
if (!task) return
const previousStart = task.start_date
const previousDeadline = task.deadline
const newStartStr = toDateString(drag.currentStart)
const newEndStr = toDateString(drag.currentEnd)
task.start_date = newStartStr
task.deadline = newEndStr
try {
await taskService.updateTask(drag.taskId, { start_date: newStartStr, deadline: newEndStr })
} catch (err: any) {
console.error('Failed to reschedule task:', err)
task.start_date = previousStart
task.deadline = previousDeadline
toast({
title: 'Error',
description: err.response?.data?.detail || 'Failed to reschedule task',
variant: 'destructive'
})
}
}
function taskBarStyle(task: TaskListItem) {
const isDragging = dragState.value?.taskId === task.id
const start = isDragging ? dragState.value!.currentStart : parseDate(task.start_date!)
const end = isDragging ? dragState.value!.currentEnd : parseDate(task.deadline!)
const left = dateToLeft(start)
const width = Math.max(dateToLeft(end) - left, 6)
return {
left: `${left}px`,
width: `${width}px`,
backgroundColor: statusColor(task)
}
}
function taskBarTitle(task: TaskListItem): string {
return `${task.name}: ${formatDate(task.start_date)} ${formatDate(task.deadline)}`
}
const legendStatuses = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId) || [])
function openTask(taskId: number) {
const task = tasks.value.find(t => t.id === taskId)
if (task) selectTask(task)
}
onMounted(() => {
loadTasks()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
})
watch(() => props.projectId, () => {
episodeFilter.value = null
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
loadTasks()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
</script>
@@ -123,12 +123,26 @@
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge> <Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
</p> </p>
</div> </div>
<div></div>
<div>
<Label class="text-muted-foreground">Start Date</Label>
<div class="mt-1">
<DatePicker
v-model="localStartDate"
placeholder="Set start date"
@update:model-value="(val) => handleDateChange('start_date', val || '')"
/>
</div>
</div>
<div> <div>
<Label class="text-muted-foreground">Deadline</Label> <Label class="text-muted-foreground">Deadline</Label>
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)"> <div class="mt-1">
<Calendar class="h-3 w-3" /> <DatePicker
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }} v-model="localDeadline"
</p> placeholder="Set deadline"
@update:model-value="(val) => handleDateChange('deadline', val || '')"
/>
</div>
</div> </div>
</div> </div>
<div> <div>
@@ -207,6 +221,8 @@
:task-type="task?.task_type" :task-type="task?.task_type"
:project-id="task?.project_id" :project-id="task?.project_id"
:name="task?.shot_name || task?.asset_name" :name="task?.shot_name || task?.asset_name"
:task-name="task?.name"
:project-name="task?.project_name"
@submissions-updated="loadSubmissions" @submissions-updated="loadSubmissions"
/> />
</TabsContent> </TabsContent>
@@ -282,10 +298,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { Play, Upload, UserPlus, Calendar, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next' import { Play, Upload, UserPlus, User, Info, MessageSquare, Paperclip } 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 { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { DatePicker } from '@/components/ui/date-picker'
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'
@@ -346,6 +363,8 @@ const task = ref<Task | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const localStatus = ref('') const localStatus = ref('')
const localStartDate = ref('')
const localDeadline = ref('')
const notes = ref<ProductionNote[]>([]) const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([]) const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
@@ -379,6 +398,8 @@ async function loadTask() {
try { try {
task.value = await taskService.getTask(props.taskId) task.value = await taskService.getTask(props.taskId)
localStatus.value = task.value.status localStatus.value = task.value.status
localStartDate.value = task.value.start_date || ''
localDeadline.value = task.value.deadline || ''
} 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'
@@ -438,6 +459,30 @@ async function handleStatusChange(newStatus: string) {
} }
} }
async function handleDateChange(field: 'start_date' | 'deadline', value: string) {
if (!task.value) return
const previous = task.value[field]
try {
const updated = await taskService.updateTask(props.taskId, { [field]: value || null } as any)
task.value[field] = updated[field]
emit('taskUpdated')
toast({
title: 'Success',
description: `Task ${field === 'start_date' ? 'start date' : 'deadline'} updated successfully`
})
} catch (error: any) {
console.error(`Error updating ${field}:`, error)
if (field === 'start_date') localStartDate.value = previous || ''
else localDeadline.value = previous || ''
toast({
title: 'Error',
description: error.response?.data?.detail || `Failed to update ${field === 'start_date' ? 'start date' : 'deadline'}`,
variant: 'destructive'
})
}
}
async function handleQuickAction(action: 'start' | 'submit') { async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return if (!task.value) return
@@ -517,19 +562,6 @@ function formatDate(dateString: string): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} }
function getDeadlineClass(deadline: string | undefined, status: string): string {
if (!deadline || status === 'approved') return 'text-muted-foreground'
const now = new Date()
const deadlineDate = new Date(deadline)
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntil < 0) return 'text-destructive'
if (daysUntil <= 3) return 'text-orange-600'
if (daysUntil <= 7) return 'text-yellow-600'
return 'text-foreground'
}
watch(() => props.taskId, () => { watch(() => props.taskId, () => {
loadTask() loadTask()
loadNotes() loadNotes()
@@ -30,7 +30,8 @@
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge> <Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
</div> </div>
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming"> <div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
Naming: <code>{{ submissionConfig.naming_pattern }}</code> {{ submissionConfig.naming_pattern_is_regex ? 'Naming (regex):' : 'Naming:' }}
<code>{{ submissionConfig.naming_pattern }}</code>
</div> </div>
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)"> <div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
Movie spec: Movie spec:
@@ -155,6 +156,8 @@ const props = defineProps<{
taskType?: string taskType?: string
projectId?: number projectId?: number
name?: string name?: string
taskName?: string
projectName?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -170,13 +173,19 @@ const viewerOpen = ref(false)
const selectedSubmission = ref<Submission | null>(null) const selectedSubmission = ref<Submission | null>(null)
const mediaBlobUrl = ref<string | null>(null) const mediaBlobUrl = ref<string | null>(null)
const submissionConfig = ref<SubmissionTypeConfig | null>(null) const submissionConfig = ref<SubmissionTypeConfig | null>(null)
const projectCode = ref<string | null>(null)
async function loadSubmissionConfig() { async function loadSubmissionConfig() {
submissionConfig.value = null submissionConfig.value = null
projectCode.value = null
if (!props.projectId || !props.taskType) return if (!props.projectId || !props.taskType) return
try { try {
const config = await projectService.getProjectSubmissionConfig(props.projectId) const config = await projectService.getProjectSubmissionConfig(props.projectId)
submissionConfig.value = config.config_by_task_type[props.taskType] || null submissionConfig.value = config.config_by_task_type[props.taskType] || null
if (submissionConfig.value?.naming_pattern?.includes('{project_code}')) {
const project = await projectService.getProject(props.projectId)
projectCode.value = project.code_name
}
} catch (error) { } catch (error) {
console.error('Failed to load submission configuration:', error) console.error('Failed to load submission configuration:', error)
} }
@@ -273,26 +282,49 @@ async function findSubmissionViolation(file: File): Promise<string | null> {
} }
if (config.check_naming && config.naming_pattern) { if (config.check_naming && config.naming_pattern) {
if (config.naming_pattern.includes('{name}') && !props.name) { if (config.naming_pattern_is_regex) {
return null // can't resolve {name} client-side try {
} const regex = new RegExp(config.naming_pattern)
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') if (!regex.test(stem)) {
const pattern = config.naming_pattern return `Filename does not match the required pattern '${config.naming_pattern}'`
.split(/(\{name\}|\{task_type\}|\{version\})/) }
.map(part => { } catch (error) {
if (part === '{name}') return escapeRegex(props.name || '') console.error('Invalid naming pattern regex:', error)
if (part === '{task_type}') return escapeRegex(props.taskType || '') }
if (part === '{version}') return '\\d{3}' } else {
return escapeRegex(part) const tokenValues: Record<string, string | undefined> = {
}) name: props.name,
.join('') task_type: props.taskType,
const regex = new RegExp(`^${pattern}$`) task_name: props.taskName,
if (!regex.test(stem)) { project_name: props.projectName,
const example = config.naming_pattern project_code: projectCode.value || undefined
.replace('{name}', props.name || 'name') }
.replace('{task_type}', props.taskType || '') const usedTokens = config.naming_pattern.match(/\{(name|task_name|project_name|project_code)\}/g) || []
.replace('{version}', '001') const unresolvable = usedTokens.some(token => !tokenValues[token.slice(1, -1)])
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')` if (!unresolvable) {
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = config.naming_pattern
.split(/(\{name\}|\{task_type\}|\{task_name\}|\{project_name\}|\{project_code\}|\{version\})/)
.map(part => {
if (part === '{version}') return '\\d{3}'
const key = part.startsWith('{') && part.endsWith('}') ? part.slice(1, -1) : null
if (key && key in tokenValues) return escapeRegex(tokenValues[key] || '')
return escapeRegex(part)
})
.join('')
const regex = new RegExp(`^${pattern}$`)
if (!regex.test(stem)) {
const example = config.naming_pattern
.replace('{name}', props.name || 'name')
.replace('{task_name}', props.taskName || 'taskname')
.replace('{project_name}', props.projectName || 'project')
.replace('{project_code}', projectCode.value || 'CODE')
.replace('{task_type}', props.taskType || '')
.replace('{version}', '001')
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
}
}
// If a token couldn't be resolved client-side, don't false-block
} }
} }
+6
View File
@@ -79,6 +79,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/project/ProjectTasksView.vue'), component: () => import('@/views/project/ProjectTasksView.vue'),
meta: { tab: 'tasks', tabLabel: 'Tasks' } meta: { tab: 'tasks', tabLabel: 'Tasks' }
}, },
{
path: 'schedule',
name: 'ProjectSchedule',
component: () => import('@/views/project/ProjectScheduleView.vue'),
meta: { tab: 'schedule', tabLabel: 'Schedule' }
},
{ {
path: 'settings', path: 'settings',
name: 'ProjectSettings', name: 'ProjectSettings',
+1
View File
@@ -97,6 +97,7 @@ export interface ProjectSettings {
export interface SubmissionTypeConfig { export interface SubmissionTypeConfig {
allowed_extensions: string[] allowed_extensions: string[]
naming_pattern_is_regex: boolean
naming_pattern?: string | null naming_pattern?: string | null
check_naming: boolean check_naming: boolean
required: boolean required: boolean
+4
View File
@@ -11,6 +11,7 @@ export interface Task {
description?: string description?: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
project_name?: string project_name?: string
@@ -33,6 +34,7 @@ export interface TaskListItem {
name: string name: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
project_name: string project_name: string
@@ -115,6 +117,7 @@ export interface TaskFilters {
status?: string status?: string
taskType?: string taskType?: string
departmentRole?: string departmentRole?: string
limit?: number
} }
export interface BulkStatusUpdateRequest { export interface BulkStatusUpdateRequest {
@@ -143,6 +146,7 @@ class TaskService {
if (filters?.status) params.append('status', filters.status) if (filters?.status) params.append('status', filters.status)
if (filters?.taskType) params.append('task_type', filters.taskType) if (filters?.taskType) params.append('task_type', filters.taskType)
if (filters?.departmentRole) params.append('department_role', filters.departmentRole) if (filters?.departmentRole) params.append('department_role', filters.departmentRole)
if (filters?.limit) params.append('limit', filters.limit.toString())
const response = await apiClient.get(`/tasks/?${params}`) const response = await apiClient.get(`/tasks/?${params}`)
return response.data return response.data
@@ -0,0 +1,50 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="px-4 sm:px-6 py-4 sm:py-6 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Schedule</h2>
<p class="text-sm text-muted-foreground mt-1">
Production schedule for project planning
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
<ScheduleGantt :project-id="projectId" />
</div>
<div v-else class="p-4 sm:p-6">
<!-- No project selected -->
<Card>
<CardContent class="p-12 text-center">
<GanttChartSquare class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-semibold mb-2">No Project Selected</h3>
<p class="text-muted-foreground mb-4">
Please select a project to view its schedule.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { GanttChartSquare } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import ScheduleGantt from '@/components/schedule/ScheduleGantt.vue'
const route = useRoute()
const projectId = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? parseInt(id) : null
})
</script>