Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c5abf6342 | |||
| f547d05478 | |||
| 981808b901 |
@@ -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!")
|
||||||
@@ -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!")
|
||||||
@@ -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!")
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +146,8 @@ 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>
|
||||||
Reference in New Issue
Block a user