Compare commits
17 Commits
960753b3d6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d67c304a3 | |||
| e1dd5c6eae | |||
| 31d17780a4 | |||
| 9bbd3df53c | |||
| c09710f4e5 | |||
| 11e1369f2f | |||
| 7f260067a2 | |||
| 04c85be0f7 | |||
| 74be250912 | |||
| 172e05af3e | |||
| 9c5abf6342 | |||
| f547d05478 | |||
| 981808b901 | |||
| 23740af816 | |||
| 762bd34f74 | |||
| 4bffec642e | |||
| 548d1081ba |
@@ -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,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to normalize projects.custom_departments from a flat list of
|
||||||
|
strings (pre-department-type feature) to a list of objects:
|
||||||
|
{"name": str, "type": "shot"|"asset", "task_types": [str]}.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_department_task_types.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_path():
|
||||||
|
"""Get the database path, trying multiple possible locations."""
|
||||||
|
possible_paths = [
|
||||||
|
"vfx_project_management.db", # Primary database
|
||||||
|
"database.db",
|
||||||
|
"../vfx_project_management.db"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return "vfx_project_management.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Normalize any legacy plain-string custom_departments entries."""
|
||||||
|
db_path = get_database_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Projects table not found. Nothing to migrate.")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute("SELECT id, custom_departments FROM projects")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
normalized_count = 0
|
||||||
|
for project_id, custom_departments_raw in rows:
|
||||||
|
if not custom_departments_raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
custom_departments = json.loads(custom_departments_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(custom_departments, list) or not custom_departments:
|
||||||
|
continue
|
||||||
|
|
||||||
|
needs_normalization = any(isinstance(d, str) for d in custom_departments)
|
||||||
|
if not needs_normalization:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for d in custom_departments:
|
||||||
|
if isinstance(d, str):
|
||||||
|
print(f" Project {project_id}: normalizing legacy department '{d}' "
|
||||||
|
f"(defaulting type='shot', task_types=['{d}'] - review if incorrect)")
|
||||||
|
normalized.append({"name": d, "type": "shot", "task_types": [d]})
|
||||||
|
else:
|
||||||
|
normalized.append(d)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE projects SET custom_departments = ? WHERE id = ?",
|
||||||
|
(json.dumps(normalized), project_id)
|
||||||
|
)
|
||||||
|
normalized_count += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"Migration completed successfully! {normalized_count} project(s) normalized.")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("VFX Project Management - Department Task Types Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrate_database()
|
||||||
|
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add the custom_departments column to the projects table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_project_custom_departments.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_path():
|
||||||
|
"""Get the database path, trying multiple possible locations."""
|
||||||
|
possible_paths = [
|
||||||
|
"vfx_project_management.db", # Primary database
|
||||||
|
"database.db",
|
||||||
|
"../vfx_project_management.db"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return "vfx_project_management.db"
|
||||||
|
|
||||||
|
|
||||||
|
def check_column_exists(cursor, table_name, column_name):
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add custom_departments column to the projects table."""
|
||||||
|
db_path = get_database_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Projects table not found. Creating new database schema...")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if check_column_exists(cursor, "projects", "custom_departments"):
|
||||||
|
print("Column custom_departments already exists, skipping...")
|
||||||
|
else:
|
||||||
|
print("Adding column: custom_departments")
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN custom_departments TEXT")
|
||||||
|
cursor.execute("UPDATE projects SET custom_departments = '[]' WHERE custom_departments IS NULL")
|
||||||
|
|
||||||
|
# project_members.department_role was previously backed by SQLAlchemy's
|
||||||
|
# Enum(DepartmentRole) type, which stores the enum MEMBER NAME (e.g. "LAYOUT"),
|
||||||
|
# not its value ("layout"). The old Optional[DepartmentRole] schema silently
|
||||||
|
# normalized this back to lowercase on read. Now that the column is a plain
|
||||||
|
# string, normalize any existing uppercase values so they match the standard
|
||||||
|
# department strings used everywhere else.
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
department_name_to_value = {
|
||||||
|
'LAYOUT': 'layout',
|
||||||
|
'ANIMATION': 'animation',
|
||||||
|
'LIGHTING': 'lighting',
|
||||||
|
'COMPOSITE': 'composite',
|
||||||
|
'MODELING': 'modeling',
|
||||||
|
'RIGGING': 'rigging',
|
||||||
|
'SURFACING': 'surfacing',
|
||||||
|
}
|
||||||
|
for old_value, new_value in department_name_to_value.items():
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE project_members SET department_role = ? WHERE department_role = ?",
|
||||||
|
(new_value, old_value)
|
||||||
|
)
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
print(f"Normalized {cursor.rowcount} project members from '{old_value}' to '{new_value}'")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||||
|
project_count = cursor.fetchone()[0]
|
||||||
|
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to '[]').")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("VFX Project Management - Project Custom Departments Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrate_database()
|
||||||
|
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add the submission_config_by_task_type column to the projects table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_project_submission_config.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 submission_config_by_task_type column to the projects table."""
|
||||||
|
db_path = get_database_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Projects table not found. Creating new database schema...")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if check_column_exists(cursor, "projects", "submission_config_by_task_type"):
|
||||||
|
print("Column submission_config_by_task_type already exists, skipping...")
|
||||||
|
else:
|
||||||
|
print("Adding column: submission_config_by_task_type")
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN submission_config_by_task_type JSON")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||||
|
project_count = cursor.fetchone()[0]
|
||||||
|
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to 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 - Submission Configuration Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrate_database()
|
||||||
|
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add the department column to the tasks table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_task_department.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_path():
|
||||||
|
"""Get the database path, trying multiple possible locations."""
|
||||||
|
possible_paths = [
|
||||||
|
"vfx_project_management.db", # Primary database
|
||||||
|
"database.db",
|
||||||
|
"../vfx_project_management.db"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return "vfx_project_management.db"
|
||||||
|
|
||||||
|
|
||||||
|
def check_column_exists(cursor, table_name, column_name):
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add department column to the tasks table."""
|
||||||
|
db_path = get_database_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Tasks table not found. Creating new database schema...")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if check_column_exists(cursor, "tasks", "department"):
|
||||||
|
print("Column department already exists, skipping...")
|
||||||
|
else:
|
||||||
|
print("Adding column: department")
|
||||||
|
cursor.execute("ALTER TABLE tasks ADD COLUMN department VARCHAR")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||||
|
task_count = cursor.fetchone()[0]
|
||||||
|
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("VFX Project Management - Task Department Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrate_database()
|
||||||
|
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add the start_date column to the tasks table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_task_start_date.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_path():
|
||||||
|
"""Get the database path, trying multiple possible locations."""
|
||||||
|
possible_paths = [
|
||||||
|
"vfx_project_management.db", # Primary database
|
||||||
|
"database.db",
|
||||||
|
"../vfx_project_management.db"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return "vfx_project_management.db"
|
||||||
|
|
||||||
|
|
||||||
|
def check_column_exists(cursor, table_name, column_name):
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add start_date column to the tasks table."""
|
||||||
|
db_path = get_database_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Tasks table not found. Creating new database schema...")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if check_column_exists(cursor, "tasks", "start_date"):
|
||||||
|
print("Column start_date already exists, skipping...")
|
||||||
|
else:
|
||||||
|
print("Adding column: start_date")
|
||||||
|
cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||||
|
task_count = cursor.fetchone()[0]
|
||||||
|
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("VFX Project Management - Task Start Date Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrate_database()
|
||||||
|
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Models package
|
# Models package
|
||||||
from .user import User, UserRole, DepartmentRole
|
from .user import User, UserRole
|
||||||
from .project import Project, ProjectMember, ProjectStatus
|
from .project import Project, ProjectMember, ProjectStatus
|
||||||
from .episode import Episode, EpisodeStatus
|
from .episode import Episode, EpisodeStatus
|
||||||
from .asset import Asset, AssetCategory, AssetStatus
|
from .asset import Asset, AssetCategory, AssetStatus
|
||||||
@@ -17,7 +17,7 @@ from .role import Role, Permission, role_permissions, user_roles
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User models
|
# User models
|
||||||
"User", "UserRole", "DepartmentRole",
|
"User", "UserRole",
|
||||||
# Project models
|
# Project models
|
||||||
"Project", "ProjectMember", "ProjectStatus",
|
"Project", "ProjectMember", "ProjectStatus",
|
||||||
# Episode models
|
# Episode models
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey
|
|||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from database import Base
|
from database import Base
|
||||||
from .user import DepartmentRole
|
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +53,12 @@ class Project(Base):
|
|||||||
# Custom task statuses
|
# Custom task statuses
|
||||||
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
||||||
|
|
||||||
|
# Custom departments
|
||||||
|
custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
|
||||||
|
|
||||||
|
# Submission configuration per task type
|
||||||
|
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
|
||||||
|
|
||||||
# Project thumbnail
|
# Project thumbnail
|
||||||
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
||||||
|
|
||||||
@@ -77,7 +82,7 @@ class ProjectMember(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
department_role = Column(Enum(DepartmentRole), nullable=True)
|
department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
|
||||||
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class Task(Base):
|
|||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
|
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
|
||||||
|
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
|
||||||
|
start_date = Column(Date)
|
||||||
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())
|
||||||
|
|||||||
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
|
|||||||
DEVELOPER = "developer"
|
DEVELOPER = "developer"
|
||||||
|
|
||||||
|
|
||||||
class DepartmentRole(str, enum.Enum):
|
|
||||||
LAYOUT = "layout"
|
|
||||||
ANIMATION = "animation"
|
|
||||||
LIGHTING = "lighting"
|
|
||||||
COMPOSITE = "composite"
|
|
||||||
MODELING = "modeling"
|
|
||||||
RIGGING = "rigging"
|
|
||||||
SURFACING = "surfacing"
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Dict
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.asset import Asset, AssetCategory
|
from models.asset import Asset, AssetCategory
|
||||||
@@ -10,6 +10,7 @@ from models.user import User, UserRole
|
|||||||
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
|
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
|
||||||
from schemas.task import TaskCreate
|
from schemas.task import TaskCreate
|
||||||
from utils.auth import get_current_user_from_token, require_permission
|
from utils.auth import get_current_user_from_token, require_permission
|
||||||
|
from utils.departments import find_owning_department
|
||||||
from services.asset_soft_deletion import AssetSoftDeletionService
|
from services.asset_soft_deletion import AssetSoftDeletionService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -119,7 +120,7 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
|
|||||||
return STANDARD_ASSET_TASK_TYPES + custom_types
|
return STANDARD_ASSET_TASK_TYPES + custom_types
|
||||||
|
|
||||||
|
|
||||||
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]:
|
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
|
||||||
"""Create default tasks for an asset."""
|
"""Create default tasks for an asset."""
|
||||||
created_tasks = []
|
created_tasks = []
|
||||||
|
|
||||||
@@ -134,7 +135,8 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
|
|||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
name=task_name,
|
name=task_name,
|
||||||
description=f"Default {task_type} task for {asset.name}",
|
description=f"Default {task_type} task for {asset.name}",
|
||||||
status="not_started"
|
status="not_started",
|
||||||
|
department=find_owning_department(project, task_type) if project else None
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
@@ -362,7 +364,7 @@ async def create_asset(
|
|||||||
):
|
):
|
||||||
"""Create a new asset in a project with optional default tasks"""
|
"""Create a new asset in a project with optional default tasks"""
|
||||||
# Check project access
|
# Check project access
|
||||||
check_project_access(project_id, current_user, db)
|
project = check_project_access(project_id, current_user, db)
|
||||||
|
|
||||||
# Check if asset name already exists in project (exclude soft deleted)
|
# Check if asset name already exists in project (exclude soft deleted)
|
||||||
existing_asset = db.query(Asset).filter(
|
existing_asset = db.query(Asset).filter(
|
||||||
@@ -408,7 +410,7 @@ async def create_asset(
|
|||||||
task_types = get_default_asset_task_types(asset.category)
|
task_types = get_default_asset_task_types(asset.category)
|
||||||
|
|
||||||
# Create the tasks
|
# Create the tasks
|
||||||
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db)
|
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
|
||||||
task_count = len(created_tasks)
|
task_count = len(created_tasks)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -553,13 +555,15 @@ async def create_asset_task(
|
|||||||
|
|
||||||
# Create the task
|
# Create the task
|
||||||
task_name = f"{asset.name} - {task_type.title()}"
|
task_name = f"{asset.name} - {task_type.title()}"
|
||||||
|
project = db.query(Project).filter(Project.id == asset.project_id).first()
|
||||||
db_task = Task(
|
db_task = Task(
|
||||||
project_id=asset.project_id,
|
project_id=asset.project_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
name=task_name,
|
name=task_name,
|
||||||
description=f"{task_type.title()} task for {asset.name}",
|
description=f"{task_type.title()} task for {asset.name}",
|
||||||
status="not_started"
|
status="not_started",
|
||||||
|
department=find_owning_department(project, task_type)
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from schemas.project import (
|
|||||||
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
||||||
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
|
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
|
||||||
)
|
)
|
||||||
|
from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
|
||||||
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -1077,6 +1078,602 @@ async def delete_custom_task_type(
|
|||||||
return _build_all_task_types_response(db_project)
|
return _build_all_task_types_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
# Department Management Endpoints
|
||||||
|
|
||||||
|
# Standard departments (read-only): name, whether they apply to shots or assets,
|
||||||
|
# and the task types they own.
|
||||||
|
STANDARD_DEPARTMENTS = [
|
||||||
|
{"name": "layout", "type": "shot", "task_types": ["layout"]},
|
||||||
|
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
|
||||||
|
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
|
||||||
|
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
|
||||||
|
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
|
||||||
|
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
|
||||||
|
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
|
||||||
|
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
|
||||||
|
]
|
||||||
|
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_custom_department(custom_departments: list, name: str):
|
||||||
|
"""Find a custom department dict by name, or None."""
|
||||||
|
for department in custom_departments:
|
||||||
|
if department["name"] == name:
|
||||||
|
return department
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_all_departments_response(db_project: Project):
|
||||||
|
"""Helper function to build AllDepartmentsResponse"""
|
||||||
|
from schemas.department import AllDepartmentsResponse, DepartmentInfo
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
|
||||||
|
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
|
||||||
|
|
||||||
|
return AllDepartmentsResponse(
|
||||||
|
departments=standard_infos + custom_infos,
|
||||||
|
standard_departments=standard_infos,
|
||||||
|
custom_departments=custom_infos
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/departments")
|
||||||
|
async def get_all_departments(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user_with_db)
|
||||||
|
):
|
||||||
|
"""Get all departments (standard + custom) for a project"""
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_department(
|
||||||
|
project_id: int,
|
||||||
|
department_data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Add a new custom department to a project"""
|
||||||
|
from schemas.department import CustomDepartmentCreate
|
||||||
|
|
||||||
|
try:
|
||||||
|
department_create = CustomDepartmentCreate(**department_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
|
||||||
|
if department_create.department in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _find_custom_department(custom_departments, department_create.department):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Department '{department_create.department}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments.append({
|
||||||
|
"name": department_create.department,
|
||||||
|
"type": department_create.department_type,
|
||||||
|
"task_types": department_create.task_types
|
||||||
|
})
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to add department"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/departments/{department}")
|
||||||
|
async def update_department(
|
||||||
|
project_id: int,
|
||||||
|
department: str,
|
||||||
|
update_data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Update a custom department name, cascading the rename to members and tasks using it"""
|
||||||
|
from schemas.department import CustomDepartmentUpdate
|
||||||
|
from models.task import Task
|
||||||
|
|
||||||
|
try:
|
||||||
|
department_update = CustomDepartmentUpdate(**update_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
if department != department_update.old_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Department in URL does not match old_name in request body"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
existing = _find_custom_department(custom_departments, department_update.old_name)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Custom department '{department_update.old_name}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Department '{department_update.new_name}' is a standard department"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (department_update.new_name != department_update.old_name
|
||||||
|
and _find_custom_department(custom_departments, department_update.new_name)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Department '{department_update.new_name}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing["name"] = department_update.new_name
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
# Cascade rename to project members using this department
|
||||||
|
members_to_update = db.query(ProjectMember).filter(
|
||||||
|
ProjectMember.project_id == project_id,
|
||||||
|
ProjectMember.department_role == department_update.old_name
|
||||||
|
).all()
|
||||||
|
for member in members_to_update:
|
||||||
|
member.department_role = department_update.new_name
|
||||||
|
|
||||||
|
# Cascade rename to tasks using this department
|
||||||
|
tasks_to_update = db.query(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.department == department_update.old_name
|
||||||
|
).all()
|
||||||
|
for task in tasks_to_update:
|
||||||
|
task.department = department_update.new_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update department"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/departments/{department}")
|
||||||
|
async def delete_department(
|
||||||
|
project_id: int,
|
||||||
|
department: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Delete a custom department (blocked if any member or task is currently using it)"""
|
||||||
|
from models.task import Task
|
||||||
|
|
||||||
|
if department in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Standard departments cannot be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
|
||||||
|
if not _find_custom_department(custom_departments, department):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Custom department '{department}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
members_using_department = db.query(ProjectMember).filter(
|
||||||
|
ProjectMember.project_id == project_id,
|
||||||
|
ProjectMember.department_role == department
|
||||||
|
).all()
|
||||||
|
|
||||||
|
tasks_using_department = db.query(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.department == department
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if members_using_department or tasks_using_department:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": f"Cannot delete department '{department}' because it is currently in use",
|
||||||
|
"department": department,
|
||||||
|
"member_count": len(members_using_department),
|
||||||
|
"task_count": len(tasks_using_department)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = [d for d in custom_departments if d["name"] != department]
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete department"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_department_task_type(
|
||||||
|
project_id: int,
|
||||||
|
department: str,
|
||||||
|
task_type_data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Add a task type to a custom department"""
|
||||||
|
from schemas.department import DepartmentTaskTypeCreate
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_type_create = DepartmentTaskTypeCreate(**task_type_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if department in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Standard departments' task types are fixed and cannot be modified"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
existing = _find_custom_department(custom_departments, department)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Custom department '{department}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_type_create.task_type in existing["task_types"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing["task_types"].append(task_type_create.task_type)
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to add department task type"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||||
|
async def rename_department_task_type(
|
||||||
|
project_id: int,
|
||||||
|
department: str,
|
||||||
|
task_type: str,
|
||||||
|
update_data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
|
||||||
|
from schemas.department import DepartmentTaskTypeUpdate
|
||||||
|
from models.task import Task
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_type_update = DepartmentTaskTypeUpdate(**update_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_type != task_type_update.old_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Task type in URL does not match old_name in request body"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if department in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Standard departments' task types are fixed and cannot be modified"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
existing = _find_custom_department(custom_departments, department)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Custom department '{department}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_type_update.old_name not in existing["task_types"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (task_type_update.new_name != task_type_update.old_name
|
||||||
|
and task_type_update.new_name in existing["task_types"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing["task_types"] = [
|
||||||
|
task_type_update.new_name if t == task_type_update.old_name else t
|
||||||
|
for t in existing["task_types"]
|
||||||
|
]
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
# Cascade rename to tasks using this task type within this department
|
||||||
|
tasks_to_update = db.query(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.department == department,
|
||||||
|
Task.task_type == task_type_update.old_name
|
||||||
|
).all()
|
||||||
|
for task in tasks_to_update:
|
||||||
|
task.task_type = task_type_update.new_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to rename department task type"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||||
|
async def delete_department_task_type(
|
||||||
|
project_id: int,
|
||||||
|
department: str,
|
||||||
|
task_type: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
|
||||||
|
from models.task import Task
|
||||||
|
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if department in STANDARD_DEPARTMENT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Standard departments' task types are fixed and cannot be modified"
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_departments = db_project.custom_departments or []
|
||||||
|
existing = _find_custom_department(custom_departments, department)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Custom department '{department}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_type not in existing["task_types"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task type '{task_type}' not found in department '{department}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks_using_task_type = db.query(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.department == department,
|
||||||
|
Task.task_type == task_type
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if tasks_using_task_type:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
|
||||||
|
"department": department,
|
||||||
|
"task_type": task_type,
|
||||||
|
"task_count": len(tasks_using_task_type)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
|
||||||
|
|
||||||
|
db_project.custom_departments = custom_departments
|
||||||
|
flag_modified(db_project, 'custom_departments')
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete department task type"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_all_departments_response(db_project)
|
||||||
|
|
||||||
|
|
||||||
|
# Submission Configuration Endpoints
|
||||||
|
|
||||||
|
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||||
|
async def get_project_submission_config(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user_with_db)
|
||||||
|
):
|
||||||
|
"""Get the project's per-task-type submission configuration"""
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user.role == UserRole.ARTIST:
|
||||||
|
member = db.query(ProjectMember).filter(
|
||||||
|
ProjectMember.project_id == project_id,
|
||||||
|
ProjectMember.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied to this project"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_data = project.submission_config_by_task_type
|
||||||
|
if isinstance(config_data, str):
|
||||||
|
try:
|
||||||
|
config_data = json.loads(config_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
config_data = {}
|
||||||
|
|
||||||
|
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||||
|
async def update_project_submission_config(
|
||||||
|
project_id: int,
|
||||||
|
submission_config: ProjectSubmissionConfig,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_coordinator_or_admin)
|
||||||
|
):
|
||||||
|
"""Replace the project's per-task-type submission configuration"""
|
||||||
|
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
all_types_response = _build_all_task_types_response(db_project)
|
||||||
|
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
|
||||||
|
for task_type in submission_config.config_by_task_type.keys():
|
||||||
|
if task_type not in valid_task_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"'{task_type}' is not a valid task type for this project"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_project.submission_config_by_task_type = {
|
||||||
|
task_type: config.dict()
|
||||||
|
for task_type, config in submission_config.config_by_task_type.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_project)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update submission configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
|
||||||
|
|
||||||
|
|
||||||
# Project Thumbnail Management Endpoints
|
# Project Thumbnail Management Endpoints
|
||||||
|
|
||||||
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from schemas.shot import (
|
|||||||
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
||||||
)
|
)
|
||||||
from utils.auth import get_current_user_from_token, require_permission
|
from utils.auth import get_current_user_from_token, require_permission
|
||||||
|
from utils.departments import find_owning_department
|
||||||
from services.shot_soft_deletion import ShotSoftDeletionService
|
from services.shot_soft_deletion import ShotSoftDeletionService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -131,7 +132,7 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
|
|||||||
return STANDARD_SHOT_TASK_TYPES + custom_types
|
return STANDARD_SHOT_TASK_TYPES + custom_types
|
||||||
|
|
||||||
|
|
||||||
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
|
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
|
||||||
"""Create default tasks for a shot."""
|
"""Create default tasks for a shot."""
|
||||||
created_tasks = []
|
created_tasks = []
|
||||||
|
|
||||||
@@ -145,7 +146,8 @@ def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session
|
|||||||
shot_id=shot.id,
|
shot_id=shot.id,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
name=task_name,
|
name=task_name,
|
||||||
description=task_description
|
description=task_description,
|
||||||
|
department=find_owning_department(project, task_type) if project else None
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(task)
|
db.add(task)
|
||||||
@@ -420,7 +422,8 @@ async def create_shot(
|
|||||||
all_task_types = get_all_shot_task_types(episode.project_id, db)
|
all_task_types = get_all_shot_task_types(episode.project_id, db)
|
||||||
# Use default standard types for now (can be customized via project settings)
|
# Use default standard types for now (can be customized via project settings)
|
||||||
default_task_types = get_default_shot_task_types()
|
default_task_types = get_default_shot_task_types()
|
||||||
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
|
project = db.query(Project).filter(Project.id == episode.project_id).first()
|
||||||
|
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
|
||||||
db.commit()
|
db.commit()
|
||||||
task_count = len(created_tasks)
|
task_count = len(created_tasks)
|
||||||
|
|
||||||
@@ -498,6 +501,7 @@ async def create_shots_bulk(
|
|||||||
|
|
||||||
created_shots = []
|
created_shots = []
|
||||||
total_tasks_created = 0
|
total_tasks_created = 0
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create all shots - validation already done above
|
# Create all shots - validation already done above
|
||||||
@@ -525,7 +529,7 @@ async def create_shots_bulk(
|
|||||||
# Create default tasks if requested
|
# Create default tasks if requested
|
||||||
task_count = 0
|
task_count = 0
|
||||||
if bulk_shot.create_default_tasks:
|
if bulk_shot.create_default_tasks:
|
||||||
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
|
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
|
||||||
task_count = len(created_tasks)
|
task_count = len(created_tasks)
|
||||||
total_tasks_created += task_count
|
total_tasks_created += task_count
|
||||||
|
|
||||||
@@ -694,6 +698,7 @@ async def create_shot_task(
|
|||||||
|
|
||||||
# Create the task
|
# Create the task
|
||||||
task_name = f"{shot.name} - {task_type.title()}"
|
task_name = f"{shot.name} - {task_type.title()}"
|
||||||
|
project = db.query(Project).filter(Project.id == shot.project_id).first()
|
||||||
db_task = Task(
|
db_task = Task(
|
||||||
project_id=shot.project_id,
|
project_id=shot.project_id,
|
||||||
episode_id=shot.episode_id,
|
episode_id=shot.episode_id,
|
||||||
@@ -701,7 +706,8 @@ async def create_shot_task(
|
|||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
name=task_name,
|
name=task_name,
|
||||||
description=f"{task_type.title()} task for {shot.name}",
|
description=f"{task_type.title()} task for {shot.name}",
|
||||||
status="not_started"
|
status="not_started",
|
||||||
|
department=find_owning_department(project, task_type)
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
|
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
|
||||||
from models.user import User, UserRole, DepartmentRole
|
from models.user import User, UserRole
|
||||||
from models.project import Project, ProjectMember
|
from models.project import Project, ProjectMember
|
||||||
from models.asset import Asset
|
from models.asset import Asset
|
||||||
from models.shot import Shot
|
from models.shot import Shot
|
||||||
@@ -19,12 +19,13 @@ from schemas.task import (
|
|||||||
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
||||||
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
||||||
TaskAttachmentCreate, TaskAttachmentResponse,
|
TaskAttachmentCreate, TaskAttachmentResponse,
|
||||||
SubmissionCreate, SubmissionUpdate, SubmissionResponse,
|
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
|
||||||
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
||||||
)
|
)
|
||||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
||||||
from utils.notifications import notification_service
|
from utils.notifications import notification_service
|
||||||
from utils.file_handler import file_handler
|
from utils.file_handler import file_handler
|
||||||
|
from utils.departments import find_owning_department
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -248,6 +249,8 @@ async def get_tasks(
|
|||||||
"name": task.name,
|
"name": task.name,
|
||||||
"task_type": task.task_type,
|
"task_type": task.task_type,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
|
"department": task.department,
|
||||||
|
"start_date": task.start_date,
|
||||||
"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 +332,8 @@ async def get_my_tasks(
|
|||||||
name=task.name,
|
name=task.name,
|
||||||
task_type=task.task_type,
|
task_type=task.task_type,
|
||||||
status=task.status,
|
status=task.status,
|
||||||
|
department=task.department,
|
||||||
|
start_date=task.start_date,
|
||||||
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",
|
||||||
@@ -415,6 +420,12 @@ async def create_task(
|
|||||||
detail=f"Invalid status '{task_data['status']}' for this project"
|
detail=f"Invalid status '{task_data['status']}' for this project"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If the task type belongs to a department, that department wins over
|
||||||
|
# anything explicitly submitted for `department`.
|
||||||
|
owning_department = find_owning_department(project, task_data.get('task_type'))
|
||||||
|
if owning_department:
|
||||||
|
task_data['department'] = owning_department
|
||||||
|
|
||||||
# Create task
|
# Create task
|
||||||
db_task = Task(**task_data)
|
db_task = Task(**task_data)
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
@@ -680,6 +691,26 @@ async def bulk_assign_tasks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/submission-dates", response_model=List[SubmissionDateInfo])
|
||||||
|
async def get_submission_dates(
|
||||||
|
project_id: int = Query(..., description="Project ID to fetch submission dates for"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a lightweight list of (task_id, submitted_at) for every non-deleted
|
||||||
|
submission on a project's tasks, for rendering submission markers (e.g.
|
||||||
|
on the Schedule Gantt chart) without fetching full submission payloads.
|
||||||
|
"""
|
||||||
|
submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter(
|
||||||
|
Task.project_id == project_id,
|
||||||
|
Task.deleted_at.is_(None),
|
||||||
|
Submission.deleted_at.is_(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=TaskResponse)
|
@router.get("/{task_id}", response_model=TaskResponse)
|
||||||
async def get_task(
|
async def get_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -718,6 +749,8 @@ async def get_task(
|
|||||||
"description": task.description,
|
"description": task.description,
|
||||||
"task_type": task.task_type,
|
"task_type": task.task_type,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
|
"department": task.department,
|
||||||
|
"start_date": task.start_date,
|
||||||
"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,
|
||||||
@@ -804,6 +837,14 @@ async def update_task(
|
|||||||
detail=f"Invalid status '{update_data['status']}' for this project"
|
detail=f"Invalid status '{update_data['status']}' for this project"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If the task type is being changed to one owned by a department, that
|
||||||
|
# department wins over anything explicitly submitted for `department`.
|
||||||
|
if 'task_type' in update_data:
|
||||||
|
project = db.query(Project).filter(Project.id == task.project_id).first()
|
||||||
|
owning_department = find_owning_department(project, update_data['task_type'])
|
||||||
|
if owning_department:
|
||||||
|
update_data['department'] = owning_department
|
||||||
|
|
||||||
# Update task
|
# Update task
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(task, field, value)
|
setattr(task, field, value)
|
||||||
@@ -843,6 +884,8 @@ async def update_task(
|
|||||||
"description": task.description,
|
"description": task.description,
|
||||||
"task_type": task.task_type,
|
"task_type": task.task_type,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
|
"department": task.department,
|
||||||
|
"start_date": task.start_date,
|
||||||
"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 +977,8 @@ async def update_task_status(
|
|||||||
"description": task.description,
|
"description": task.description,
|
||||||
"task_type": task.task_type,
|
"task_type": task.task_type,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
|
"department": task.department,
|
||||||
|
"start_date": task.start_date,
|
||||||
"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,
|
||||||
@@ -999,13 +1044,13 @@ async def assign_task(
|
|||||||
|
|
||||||
# Check if user's department role matches task type (optional validation)
|
# Check if user's department role matches task type (optional validation)
|
||||||
task_to_department_mapping = {
|
task_to_department_mapping = {
|
||||||
"layout": DepartmentRole.LAYOUT,
|
"layout": "layout",
|
||||||
"animation": DepartmentRole.ANIMATION,
|
"animation": "animation",
|
||||||
"lighting": DepartmentRole.LIGHTING,
|
"lighting": "lighting",
|
||||||
"compositing": DepartmentRole.COMPOSITE,
|
"compositing": "composite",
|
||||||
"modeling": DepartmentRole.MODELING,
|
"modeling": "modeling",
|
||||||
"rigging": DepartmentRole.RIGGING,
|
"rigging": "rigging",
|
||||||
"surfacing": DepartmentRole.SURFACING,
|
"surfacing": "surfacing",
|
||||||
"simulation": None # Simulation can be handled by multiple departments
|
"simulation": None # Simulation can be handled by multiple departments
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1084,8 @@ async def assign_task(
|
|||||||
"description": task.description,
|
"description": task.description,
|
||||||
"task_type": task.task_type,
|
"task_type": task.task_type,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
|
"department": task.department,
|
||||||
|
"start_date": task.start_date,
|
||||||
"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,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for department management
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
from typing import List, Literal
|
||||||
|
import re
|
||||||
|
|
||||||
|
DEPARTMENT_NAME_PATTERN = r'^[a-z0-9_]{2,50}$'
|
||||||
|
|
||||||
|
|
||||||
|
class CustomDepartmentCreate(BaseModel):
|
||||||
|
"""Schema for creating a new custom department"""
|
||||||
|
department: str = Field(..., min_length=2, max_length=50, description="Department name")
|
||||||
|
department_type: Literal["shot", "asset"] = Field(..., description="Whether this department applies to shots or assets")
|
||||||
|
task_types: List[str] = Field(default_factory=list, description="Task types owned by this department")
|
||||||
|
|
||||||
|
@validator('department')
|
||||||
|
def validate_department_name(cls, v):
|
||||||
|
"""Validate department name format"""
|
||||||
|
if not re.match(DEPARTMENT_NAME_PATTERN, v):
|
||||||
|
raise ValueError(
|
||||||
|
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('task_types', each_item=True)
|
||||||
|
def validate_task_type_name(cls, v):
|
||||||
|
if not re.match(DEPARTMENT_NAME_PATTERN, v):
|
||||||
|
raise ValueError(
|
||||||
|
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CustomDepartmentUpdate(BaseModel):
|
||||||
|
"""Schema for updating a custom department name"""
|
||||||
|
old_name: str = Field(..., description="Current department name")
|
||||||
|
new_name: str = Field(..., min_length=2, max_length=50, description="New department name")
|
||||||
|
|
||||||
|
@validator('new_name')
|
||||||
|
def validate_department_name(cls, v):
|
||||||
|
"""Validate department name format"""
|
||||||
|
if not re.match(DEPARTMENT_NAME_PATTERN, v):
|
||||||
|
raise ValueError(
|
||||||
|
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentTaskTypeCreate(BaseModel):
|
||||||
|
"""Schema for adding a task type to a custom department"""
|
||||||
|
task_type: str = Field(..., min_length=2, max_length=50, description="Task type name")
|
||||||
|
|
||||||
|
@validator('task_type')
|
||||||
|
def validate_task_type_name(cls, v):
|
||||||
|
if not re.match(DEPARTMENT_NAME_PATTERN, v):
|
||||||
|
raise ValueError(
|
||||||
|
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentTaskTypeUpdate(BaseModel):
|
||||||
|
"""Schema for renaming a task type within a custom department"""
|
||||||
|
old_name: str = Field(..., description="Current task type name")
|
||||||
|
new_name: str = Field(..., min_length=2, max_length=50, description="New task type name")
|
||||||
|
|
||||||
|
@validator('new_name')
|
||||||
|
def validate_task_type_name(cls, v):
|
||||||
|
if not re.match(DEPARTMENT_NAME_PATTERN, v):
|
||||||
|
raise ValueError(
|
||||||
|
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentInfo(BaseModel):
|
||||||
|
"""A single department: its name, whether it applies to shots or assets, and its owned task types"""
|
||||||
|
name: str
|
||||||
|
type: Literal["shot", "asset"]
|
||||||
|
task_types: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AllDepartmentsResponse(BaseModel):
|
||||||
|
"""Schema for response containing all departments (standard + custom)"""
|
||||||
|
departments: List[DepartmentInfo] = Field(..., description="All departments")
|
||||||
|
standard_departments: List[DepartmentInfo] = Field(..., description="Standard departments (read-only)")
|
||||||
|
custom_departments: List[DepartmentInfo] = Field(..., description="Custom departments")
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentInUseError(BaseModel):
|
||||||
|
"""Schema for error when trying to delete a department in use"""
|
||||||
|
error: str = Field(..., description="Error message")
|
||||||
|
department: str = Field(..., description="Department that is in use")
|
||||||
|
member_count: int = Field(..., description="Number of project members using this department")
|
||||||
|
task_count: int = Field(..., description="Number of tasks using this department")
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentTaskTypeInUseError(BaseModel):
|
||||||
|
"""Schema for error when trying to delete a department task type in use"""
|
||||||
|
error: str = Field(..., description="Error message")
|
||||||
|
department: str = Field(..., description="Department the task type belongs to")
|
||||||
|
task_type: str = Field(..., description="Task type that is in use")
|
||||||
|
task_count: int = Field(..., description="Number of tasks using this task type")
|
||||||
@@ -5,7 +5,6 @@ from enum import Enum
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from models.project import ProjectStatus, ProjectType
|
from models.project import ProjectStatus, ProjectType
|
||||||
from models.user import DepartmentRole
|
|
||||||
|
|
||||||
|
|
||||||
# Technical Specifications Schemas
|
# Technical Specifications Schemas
|
||||||
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
|
|||||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator('delivery_movie_specs_by_department')
|
|
||||||
def validate_delivery_movie_specs_by_department(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return {}
|
|
||||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
|
||||||
for dept in v.keys():
|
|
||||||
if dept not in allowed_departments:
|
|
||||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
# Default delivery movie specifications per department
|
# Default delivery movie specifications per department
|
||||||
DEFAULT_DELIVERY_MOVIE_SPECS = {
|
DEFAULT_DELIVERY_MOVIE_SPECS = {
|
||||||
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
|
|||||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator('delivery_movie_specs_by_department')
|
|
||||||
def validate_delivery_movie_specs_by_department(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return {}
|
|
||||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
|
||||||
for dept in v.keys():
|
|
||||||
if dept not in allowed_departments:
|
|
||||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(ProjectBase):
|
class ProjectCreate(ProjectBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
|
|||||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator('delivery_movie_specs_by_department')
|
|
||||||
def validate_delivery_movie_specs_by_department(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
|
||||||
for dept in v.keys():
|
|
||||||
if dept not in allowed_departments:
|
|
||||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberBase(BaseModel):
|
class ProjectMemberBase(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
department_role: Optional[DepartmentRole] = None
|
department_role: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberCreate(ProjectMemberBase):
|
class ProjectMemberCreate(ProjectMemberBase):
|
||||||
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectMemberUpdate(BaseModel):
|
class ProjectMemberUpdate(BaseModel):
|
||||||
department_role: Optional[DepartmentRole] = None
|
department_role: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberResponse(ProjectMemberBase):
|
class ProjectMemberResponse(ProjectMemberBase):
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for per-task-type submission configuration
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
import re
|
||||||
|
|
||||||
|
from utils.file_handler import file_handler
|
||||||
|
|
||||||
|
# Tokens supported in a token-mode naming_pattern
|
||||||
|
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_\-\.]*$')
|
||||||
|
|
||||||
|
|
||||||
|
MOVIE_FORMATS = {ext.lstrip('.') for ext in file_handler.MOVIE_EXTENSIONS}
|
||||||
|
MOVIE_CODECS = {'h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform'}
|
||||||
|
MOVIE_FRAME_RATES = {23.976, 24, 30, 48, 60}
|
||||||
|
RESOLUTION_RE = re.compile(r'^\d+x\d+$')
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionTypeConfig(BaseModel):
|
||||||
|
"""Submission rules for a single task type.
|
||||||
|
|
||||||
|
All checking against these rules happens client-side (in TaskSubmissions.vue) when an
|
||||||
|
artist picks a file to submit - this schema only validates and stores the configuration
|
||||||
|
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']")
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
movie_resolution: Optional[str] = Field(None, description="Required video resolution, e.g. '1920x1080'")
|
||||||
|
movie_format: Optional[str] = Field(None, description="Required video format, e.g. 'mov'")
|
||||||
|
movie_codec: Optional[str] = Field(None, description="Required video codec, e.g. 'h264'")
|
||||||
|
movie_frame_rate: Optional[float] = Field(None, description="Required video frame rate, e.g. 23.976")
|
||||||
|
|
||||||
|
@validator('allowed_extensions')
|
||||||
|
def validate_allowed_extensions(cls, v):
|
||||||
|
normalized = []
|
||||||
|
for ext in v:
|
||||||
|
ext = ext.lower()
|
||||||
|
if not ext.startswith('.'):
|
||||||
|
ext = f'.{ext}'
|
||||||
|
if ext not in file_handler.SUPPORTED_FORMATS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Extension '{ext}' is not a supported file format. "
|
||||||
|
f"Supported formats: {', '.join(sorted(file_handler.SUPPORTED_FORMATS))}"
|
||||||
|
)
|
||||||
|
normalized.append(ext)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@validator('naming_pattern')
|
||||||
|
def validate_naming_pattern(cls, v, values):
|
||||||
|
if v is None or v == '':
|
||||||
|
return None
|
||||||
|
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)
|
||||||
|
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
|
||||||
|
raise ValueError(
|
||||||
|
'Naming pattern may only contain letters, numbers, "_", "-", "." '
|
||||||
|
'and the tokens {name}, {task_type}, {task_name}, {project_name}, {project_code}, {version}'
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('movie_resolution')
|
||||||
|
def validate_movie_resolution(cls, v):
|
||||||
|
if v is None or v == '':
|
||||||
|
return None
|
||||||
|
if not RESOLUTION_RE.match(v):
|
||||||
|
raise ValueError('Movie resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('movie_format')
|
||||||
|
def validate_movie_format(cls, v):
|
||||||
|
if v is None or v == '':
|
||||||
|
return None
|
||||||
|
v = v.lower().lstrip('.')
|
||||||
|
if v not in MOVIE_FORMATS:
|
||||||
|
raise ValueError(f'Movie format must be one of: {", ".join(sorted(MOVIE_FORMATS))}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('movie_codec')
|
||||||
|
def validate_movie_codec(cls, v):
|
||||||
|
if v is None or v == '':
|
||||||
|
return None
|
||||||
|
v = v.lower()
|
||||||
|
if v not in MOVIE_CODECS:
|
||||||
|
raise ValueError(f'Movie codec must be one of: {", ".join(sorted(MOVIE_CODECS))}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('movie_frame_rate')
|
||||||
|
def validate_movie_frame_rate(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if not any(abs(v - rate) < 0.001 for rate in MOVIE_FRAME_RATES):
|
||||||
|
raise ValueError(f'Movie frame rate must be one of: {", ".join(str(r) for r in sorted(MOVIE_FRAME_RATES))}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSubmissionConfig(BaseModel):
|
||||||
|
"""Submission configuration for a project, keyed by task type"""
|
||||||
|
config_by_task_type: Dict[str, SubmissionTypeConfig] = Field(default_factory=dict)
|
||||||
+15
-1
@@ -4,15 +4,16 @@ from datetime import date, datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
||||||
from models.user import DepartmentRole
|
|
||||||
|
|
||||||
|
|
||||||
class TaskBase(BaseModel):
|
class TaskBase(BaseModel):
|
||||||
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
|
||||||
|
department: Optional[str] = None # Standard or project-custom department, independent of assignee
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskBase):
|
class TaskCreate(TaskBase):
|
||||||
@@ -27,8 +28,10 @@ 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
|
||||||
|
department: Optional[str] = None # Standard or project-custom department, independent of assignee
|
||||||
assigned_user_id: Optional[int] = None
|
assigned_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +71,8 @@ class TaskListResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||||
|
department: Optional[str] = None # Standard or project-custom department, independent of assignee
|
||||||
|
start_date: Optional[date] = None
|
||||||
deadline: Optional[date] = None
|
deadline: Optional[date] = None
|
||||||
project_id: int
|
project_id: int
|
||||||
project_name: str
|
project_name: str
|
||||||
@@ -192,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionDateInfo(BaseModel):
|
||||||
|
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
|
||||||
|
task_id: int
|
||||||
|
submitted_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Review schemas
|
# Review schemas
|
||||||
class ReviewBase(BaseModel):
|
class ReviewBase(BaseModel):
|
||||||
decision: ReviewDecision
|
decision: ReviewDecision
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Shared helpers for resolving the department that owns a given task type."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def find_owning_department(db_project, task_type: str) -> Optional[str]:
|
||||||
|
"""Return the name of the department (standard or custom) whose task_types
|
||||||
|
list contains task_type for this project, or None if no department owns it."""
|
||||||
|
if not task_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from routers.projects import STANDARD_DEPARTMENTS
|
||||||
|
|
||||||
|
for department in STANDARD_DEPARTMENTS:
|
||||||
|
if task_type in department["task_types"]:
|
||||||
|
return department["name"]
|
||||||
|
|
||||||
|
for department in (db_project.custom_departments or []):
|
||||||
|
if task_type in department.get("task_types", []):
|
||||||
|
return department["name"]
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -21,13 +21,15 @@ class FileHandler:
|
|||||||
# Supported VFX media formats
|
# Supported VFX media formats
|
||||||
SUPPORTED_FORMATS = {
|
SUPPORTED_FORMATS = {
|
||||||
# Video formats
|
# Video formats
|
||||||
'.mov', '.mp4', '.avi', '.mkv', '.webm',
|
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||||
# Image formats
|
# Image formats
|
||||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||||
# Document formats
|
# Document formats
|
||||||
'.pdf', '.txt', '.doc', '.docx',
|
'.pdf', '.txt', '.doc', '.docx',
|
||||||
# Archive formats
|
# Archive formats
|
||||||
'.zip', '.rar', '.7z'
|
'.zip', '.rar', '.7z',
|
||||||
|
# Scene formats
|
||||||
|
'.ma', '.usd', '.usda', '.usdc'
|
||||||
}
|
}
|
||||||
|
|
||||||
# File size limits (in bytes)
|
# File size limits (in bytes)
|
||||||
@@ -35,7 +37,7 @@ class FileHandler:
|
|||||||
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
|
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
|
||||||
|
|
||||||
# Movie file extensions that should use global upload limit
|
# Movie file extensions that should use global upload limit
|
||||||
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
|
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
|
||||||
|
|
||||||
# Thumbnail settings
|
# Thumbnail settings
|
||||||
THUMBNAIL_SIZE = (200, 200)
|
THUMBNAIL_SIZE = (200, 200)
|
||||||
|
|||||||
Generated
+137
@@ -16,6 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.294.0",
|
"lucide-vue-next": "^0.294.0",
|
||||||
|
"mediainfo.js": "^0.3.7",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"reka-ui": "^2.6.1",
|
"reka-ui": "^2.6.1",
|
||||||
"shadcn-vue": "^2.3.2",
|
"shadcn-vue": "^2.3.2",
|
||||||
@@ -2789,6 +2790,60 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^7.2.0",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/string-width": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^10.3.0",
|
||||||
|
"get-east-asian-width": "^1.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.2.1",
|
||||||
|
"string-width": "^7.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -4053,6 +4108,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-east-asian-width": {
|
"node_modules/get-east-asian-width": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||||
@@ -4817,6 +4881,21 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mediainfo.js": {
|
||||||
|
"version": "0.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz",
|
||||||
|
"integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"yargs": "^18.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mediainfo.js": "dist/esm/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
@@ -7595,12 +7674,70 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "18.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||||
|
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^9.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"string-width": "^7.2.0",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^22.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "22.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||||
|
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/emoji-regex": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/string-width": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^10.3.0",
|
||||||
|
"get-east-asian-width": "^1.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.294.0",
|
"lucide-vue-next": "^0.294.0",
|
||||||
|
"mediainfo.js": "^0.3.7",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"reka-ui": "^2.6.1",
|
"reka-ui": "^2.6.1",
|
||||||
"shadcn-vue": "^2.3.2",
|
"shadcn-vue": "^2.3.2",
|
||||||
|
|||||||
@@ -10,16 +10,9 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Asset Details -->
|
<!-- Asset Details -->
|
||||||
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<template #badges>
|
||||||
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-2 h-2 rounded-full mr-1"
|
|
||||||
:class="getStatusColor(asset.status)"
|
|
||||||
></div>
|
|
||||||
{{ formatStatus(asset.status) }}
|
|
||||||
</Badge>
|
|
||||||
<!-- Deletion status indicator for admins -->
|
<!-- Deletion status indicator for admins -->
|
||||||
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
||||||
@@ -28,18 +21,28 @@
|
|||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">
|
<Info class="h-4 w-4" />
|
||||||
Notes
|
<span class="sr-only">Infos</span>
|
||||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
|
||||||
{{ notes.length }}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="references">
|
<TabsTrigger value="notes" title="Notes">
|
||||||
References
|
<span class="relative inline-flex">
|
||||||
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
v-if="notes.length > 0"
|
||||||
|
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">Notes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="references" title="References">
|
||||||
|
<Image class="h-4 w-4" />
|
||||||
|
<span class="sr-only">References</span>
|
||||||
|
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ references.length }}
|
{{ references.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -252,7 +255,13 @@
|
|||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
|
<EntityNotes
|
||||||
|
:key="assetId"
|
||||||
|
:tasks="tasks"
|
||||||
|
:notes="notes"
|
||||||
|
:submissions="submissions"
|
||||||
|
@notes-updated="loadNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- References Tab -->
|
<!-- References Tab -->
|
||||||
@@ -267,7 +276,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Plus, MessageSquarePlus, Paperclip, Send
|
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -277,13 +286,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import AssetNotes from './AssetNotes.vue'
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
import AssetReferences from './AssetReferences.vue'
|
import AssetReferences from './AssetReferences.vue'
|
||||||
|
|
||||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: number
|
id: number
|
||||||
@@ -312,10 +322,12 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const asset = ref<Asset | null>(null)
|
const asset = ref<Asset | null>(null)
|
||||||
const notes = ref<any[]>([])
|
const notes = ref<ProductionNote[]>([])
|
||||||
|
const submissions = ref<Submission[]>([])
|
||||||
const references = ref<any[]>([])
|
const references = ref<any[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -353,9 +365,15 @@ const progressPercentage = computed(() => {
|
|||||||
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Task types offered in "Add Task" include the flat asset task type list
|
||||||
|
// plus every task type owned by an asset department (e.g. Modeling's own
|
||||||
|
// task types), deduped against types the asset already has a task for.
|
||||||
const availableTaskTypes = computed(() => {
|
const availableTaskTypes = computed(() => {
|
||||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
|
||||||
|
.flatMap(d => d.task_types)
|
||||||
|
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
|
||||||
|
return merged.filter(type => !existingTypes.has(type))
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskStatusCounts = computed(() => {
|
const taskStatusCounts = computed(() => {
|
||||||
@@ -384,6 +402,7 @@ const loadAssetDetails = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
asset.value = await assetService.getAsset(props.assetId)
|
asset.value = await assetService.getAsset(props.assetId)
|
||||||
|
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||||
|
|
||||||
// Load users if not already loaded (for user name resolution)
|
// Load users if not already loaded (for user name resolution)
|
||||||
if (userStore.users.length === 0) {
|
if (userStore.users.length === 0) {
|
||||||
@@ -402,28 +421,18 @@ const loadAssetDetails = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
|
if (tasks.value.length === 0) {
|
||||||
|
notes.value = []
|
||||||
|
submissions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Load notes from all tasks associated with this asset
|
const [notesByTask, submissionsByTask] = await Promise.all([
|
||||||
const { taskService } = await import('@/services/task')
|
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
||||||
const allNotes: any[] = []
|
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
||||||
|
])
|
||||||
for (const task of tasks.value) {
|
notes.value = notesByTask.flat()
|
||||||
if (task.id) {
|
submissions.value = submissionsByTask.flat()
|
||||||
const taskNotes = await taskService.getTaskNotes(task.id)
|
|
||||||
// Add task info to each note for context
|
|
||||||
const notesWithContext = taskNotes.map(note => ({
|
|
||||||
...note,
|
|
||||||
task_name: task.name,
|
|
||||||
task_type: task.task_type
|
|
||||||
}))
|
|
||||||
allNotes.push(...notesWithContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date (newest first)
|
|
||||||
notes.value = allNotes.sort((a, b) =>
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load notes:', err)
|
console.error('Failed to load notes:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Notes History (Top) -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
|
||||||
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
||||||
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
|
||||||
<p class="text-sm">No notes yet for this asset's tasks.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="note in notes"
|
|
||||||
:key="note.id"
|
|
||||||
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Note Header -->
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-sm font-medium">{{ note.author_name }}</span>
|
|
||||||
<Badge variant="outline" class="text-xs">
|
|
||||||
{{ formatTaskType(note.task_type) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
{{ note.task_name }} • {{ formatDate(note.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note Content -->
|
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
assetId: number
|
|
||||||
notes: any[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
notesUpdated: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatTaskType(taskType: string): string {
|
|
||||||
return taskType.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -8,13 +8,53 @@
|
|||||||
<Breadcrumb class="flex-1">
|
<Breadcrumb class="flex-1">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||||
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
|
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||||
|
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
<ChevronDown class="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
|
||||||
|
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
|
||||||
|
{{ project.name }}
|
||||||
|
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
|
||||||
|
</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem as-child>
|
||||||
|
<router-link to="/projects">All Projects</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||||
|
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
<ChevronDown class="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
|
||||||
|
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
|
||||||
|
{{ tabItem.label }}
|
||||||
|
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
|
||||||
|
</router-link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
|
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
@@ -76,7 +116,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -97,10 +137,11 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb'
|
} from '@/components/ui/breadcrumb'
|
||||||
import { User, Settings, LogOut } from 'lucide-vue-next'
|
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
||||||
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
||||||
import NotificationCenter from './NotificationCenter.vue'
|
import NotificationCenter from './NotificationCenter.vue'
|
||||||
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
|
|||||||
// Generate breadcrumbs based on current route with enhanced context
|
// Generate breadcrumbs based on current route with enhanced context
|
||||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||||
|
|
||||||
|
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
|
||||||
|
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
|
||||||
|
const projectIdParam = computed(() => {
|
||||||
|
const id = route.params.projectId
|
||||||
|
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTab = computed(() => route.meta?.tab as string | undefined)
|
||||||
|
|
||||||
|
const projectTabItems = [
|
||||||
|
{ tab: 'overview', label: 'Overview', path: '' },
|
||||||
|
{ tab: 'shots', label: 'Shots', path: '/shots' },
|
||||||
|
{ tab: 'assets', label: 'Assets', path: '/assets' },
|
||||||
|
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
|
||||||
|
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
|
||||||
|
{ tab: 'settings', label: 'Settings', path: '/settings' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
const projectsForSwitcher = computed(() => projectsStore.projects)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||||
|
projectsStore.fetchProjects()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
|
||||||
|
// so the trailing ">" separator can be skipped for it.
|
||||||
|
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
|
||||||
|
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
|
||||||
|
if (crumb.isTabCrumb) return !!projectIdParam.value
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const updateBreadcrumbs = async () => {
|
const updateBreadcrumbs = async () => {
|
||||||
try {
|
try {
|
||||||
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
|
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
|
||||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
|
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
|
|||||||
rigging: Wrench,
|
rigging: Wrench,
|
||||||
surfacing: Paintbrush
|
surfacing: Paintbrush
|
||||||
}
|
}
|
||||||
return icons[department] || Box
|
// Fallback for project-custom departments not in the standard icon map above
|
||||||
|
return icons[department] || Tag
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFrameRateLabel = (frameRate: number) => {
|
const getFrameRateLabel = (frameRate: number) => {
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
|||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { userService } from '@/services/user'
|
import { userService } from '@/services/user'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
import type { User } from '@/types/auth'
|
import type { User } from '@/types/auth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -248,16 +249,14 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { getAvatarUrl } = useAvatarUrl()
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
|
|
||||||
const departmentRoles = [
|
const departmentRoles = computed(() => {
|
||||||
{ value: 'layout', label: 'Layout' },
|
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
|
||||||
{ value: 'animation', label: 'Animation' },
|
value: department,
|
||||||
{ value: 'lighting', label: 'Lighting' },
|
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||||
{ value: 'composite', label: 'Composite' },
|
}))
|
||||||
{ value: 'modeling', label: 'Modeling' },
|
})
|
||||||
{ value: 'rigging', label: 'Rigging' },
|
|
||||||
{ value: 'surfacing', label: 'Surfacing' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const members = ref<ProjectMember[]>([])
|
const members = ref<ProjectMember[]>([])
|
||||||
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMembers()
|
loadMembers()
|
||||||
|
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full">
|
|
||||||
<div class="grid w-full grid-cols-5 h-auto bg-muted/50 p-1 rounded-md">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
@click="setActiveTab(tab.id)"
|
|
||||||
:class="[
|
|
||||||
'flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 px-1 sm:px-3 min-h-[3rem] sm:min-h-[2.5rem] transition-all duration-200 text-center rounded-sm',
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-background shadow-sm text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1 sm:gap-2">
|
|
||||||
<component :is="tab.icon" class="h-4 w-4 flex-shrink-0" />
|
|
||||||
<span class="hidden sm:inline text-sm font-medium">{{ tab.label }}</span>
|
|
||||||
<span class="sm:hidden text-xs font-medium">{{ getMobileLabel(tab) }}</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
v-if="tab.count !== undefined"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs hidden sm:inline-flex min-w-[1.5rem] h-5"
|
|
||||||
>
|
|
||||||
{{ tab.count }}
|
|
||||||
</Badge>
|
|
||||||
<!-- Mobile count display -->
|
|
||||||
<div
|
|
||||||
v-if="tab.count !== undefined"
|
|
||||||
class="sm:hidden text-xs text-muted-foreground font-medium"
|
|
||||||
>
|
|
||||||
{{ tab.count }}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, watch } from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
import { LayoutDashboard, Camera, Package, ListTodo, Settings } from "lucide-vue-next";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: any;
|
|
||||||
route: string;
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: number;
|
|
||||||
shotCount?: number;
|
|
||||||
assetCount?: number;
|
|
||||||
taskCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Define available tabs
|
|
||||||
const tabs = computed<Tab[]>(() => [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "Overview",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
route: `/projects/${props.projectId}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "shots",
|
|
||||||
label: "Shots",
|
|
||||||
icon: Camera,
|
|
||||||
route: `/projects/${props.projectId}/shots`,
|
|
||||||
count: props.shotCount,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "assets",
|
|
||||||
label: "Assets",
|
|
||||||
icon: Package,
|
|
||||||
route: `/projects/${props.projectId}/assets`,
|
|
||||||
count: props.assetCount,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tasks",
|
|
||||||
label: "Tasks",
|
|
||||||
icon: ListTodo,
|
|
||||||
route: `/projects/${props.projectId}/tasks`,
|
|
||||||
count: props.taskCount,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
label: "Settings",
|
|
||||||
icon: Settings,
|
|
||||||
route: `/projects/${props.projectId}/settings`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Determine active tab based on current route
|
|
||||||
const activeTab = computed(() => {
|
|
||||||
const currentPath = route.path;
|
|
||||||
|
|
||||||
if (currentPath === `/projects/${props.projectId}`) {
|
|
||||||
return "overview";
|
|
||||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
|
|
||||||
return "shots";
|
|
||||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
|
|
||||||
return "assets";
|
|
||||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
|
|
||||||
return "tasks";
|
|
||||||
} else if (
|
|
||||||
currentPath.startsWith(`/projects/${props.projectId}/settings`)
|
|
||||||
) {
|
|
||||||
return "settings";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "overview";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set active tab and navigate
|
|
||||||
const setActiveTab = (tabId: string) => {
|
|
||||||
const tab = tabs.value.find((t) => t.id === tabId);
|
|
||||||
if (tab) {
|
|
||||||
router.push(tab.route).catch(() => {
|
|
||||||
// Navigation aborted (e.g. duplicate route) — safe to ignore
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get mobile label for tabs
|
|
||||||
const getMobileLabel = (tab: Tab) => {
|
|
||||||
switch (tab.id) {
|
|
||||||
case "settings":
|
|
||||||
return "Settings";
|
|
||||||
case "overview":
|
|
||||||
return "Info";
|
|
||||||
case "shots":
|
|
||||||
return "Shots";
|
|
||||||
case "assets":
|
|
||||||
return "Assets";
|
|
||||||
case "tasks":
|
|
||||||
return "Tasks";
|
|
||||||
default:
|
|
||||||
return tab.label.charAt(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watch for route changes to ensure tab state persistence
|
|
||||||
watch(
|
|
||||||
() => route.path,
|
|
||||||
() => {
|
|
||||||
// Tab state is automatically updated via activeTab computed property
|
|
||||||
// This ensures tab state persistence during project navigation
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Submission Configuration</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading submission configuration...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs v-else default-value="shot" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="shot">Shot Tasks</TabsTrigger>
|
||||||
|
<TabsTrigger value="asset">Asset Tasks</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="shot" class="space-y-4 mt-4">
|
||||||
|
<SubmissionTypeConfigCard
|
||||||
|
v-for="taskType in shotTaskTypes"
|
||||||
|
:key="taskType"
|
||||||
|
:task-type="taskType"
|
||||||
|
:config="getConfig(taskType)"
|
||||||
|
/>
|
||||||
|
<div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
No shot task types configured for this project yet.
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="asset" class="space-y-4 mt-4">
|
||||||
|
<SubmissionTypeConfigCard
|
||||||
|
v-for="taskType in assetTaskTypes"
|
||||||
|
:key="taskType"
|
||||||
|
:task-type="taskType"
|
||||||
|
:config="getConfig(taskType)"
|
||||||
|
/>
|
||||||
|
<div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
No asset task types configured for this project yet.
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button :disabled="isLoading || isSaving" @click="onSave">
|
||||||
|
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Save Submission Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||||
|
import { customTaskTypeService } from '@/services/customTaskType'
|
||||||
|
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const shotTaskTypes = ref<string[]>([])
|
||||||
|
const assetTaskTypes = ref<string[]>([])
|
||||||
|
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
|
||||||
|
|
||||||
|
const getConfig = (taskType: string): SubmissionTypeConfig => {
|
||||||
|
if (!formState[taskType]) {
|
||||||
|
formState[taskType] = {
|
||||||
|
allowed_extensions: [],
|
||||||
|
naming_pattern_is_regex: false,
|
||||||
|
naming_pattern: '',
|
||||||
|
check_naming: true,
|
||||||
|
required: false,
|
||||||
|
check_movie_spec: false,
|
||||||
|
movie_resolution: '',
|
||||||
|
movie_format: '',
|
||||||
|
movie_codec: '',
|
||||||
|
movie_frame_rate: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formState[taskType]
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const [allTypes, config] = await Promise.all([
|
||||||
|
customTaskTypeService.getAllTaskTypes(props.projectId),
|
||||||
|
projectService.getProjectSubmissionConfig(props.projectId)
|
||||||
|
])
|
||||||
|
|
||||||
|
shotTaskTypes.value = allTypes.shot_task_types
|
||||||
|
assetTaskTypes.value = allTypes.asset_task_types
|
||||||
|
|
||||||
|
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
|
||||||
|
formState[taskType] = {
|
||||||
|
allowed_extensions: [...cfg.allowed_extensions],
|
||||||
|
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
|
||||||
|
naming_pattern: cfg.naming_pattern || '',
|
||||||
|
check_naming: cfg.check_naming,
|
||||||
|
required: cfg.required,
|
||||||
|
check_movie_spec: cfg.check_movie_spec,
|
||||||
|
movie_resolution: cfg.movie_resolution || '',
|
||||||
|
movie_format: cfg.movie_format || '',
|
||||||
|
movie_codec: cfg.movie_codec || '',
|
||||||
|
movie_frame_rate: cfg.movie_frame_rate ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load submission configuration:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load submission configuration',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
try {
|
||||||
|
isSaving.value = true
|
||||||
|
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
|
||||||
|
for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
|
||||||
|
const config = formState[taskType]
|
||||||
|
if (!config) continue
|
||||||
|
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|
||||||
|
|| config.required || config.check_movie_spec
|
||||||
|
if (hasConfig) {
|
||||||
|
configByTaskType[taskType] = {
|
||||||
|
allowed_extensions: config.allowed_extensions,
|
||||||
|
naming_pattern_is_regex: config.naming_pattern_is_regex,
|
||||||
|
naming_pattern: config.naming_pattern || null,
|
||||||
|
check_naming: config.check_naming,
|
||||||
|
required: config.required,
|
||||||
|
check_movie_spec: config.check_movie_spec,
|
||||||
|
movie_resolution: config.movie_resolution || null,
|
||||||
|
movie_format: config.movie_format || null,
|
||||||
|
movie_codec: config.movie_codec || null,
|
||||||
|
movie_frame_rate: config.movie_frame_rate || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Submission Configuration Updated',
|
||||||
|
description: 'Submission rules have been saved successfully'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save submission configuration:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.response?.data?.detail || 'Failed to save submission configuration',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -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,810 @@
|
|||||||
|
<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="option in GROUP_BY_OPTIONS"
|
||||||
|
:key="option"
|
||||||
|
:variant="groupBy === option ? 'secondary' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
@click="groupBy = option"
|
||||||
|
>
|
||||||
|
{{ groupByLabel(option) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 border rounded-md p-0.5">
|
||||||
|
<Button
|
||||||
|
v-for="scale in SCALES"
|
||||||
|
: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 flex flex-col overflow-hidden">
|
||||||
|
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
|
||||||
|
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
|
||||||
|
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rowGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
|
||||||
|
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex-1 flex overflow-hidden">
|
||||||
|
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
|
||||||
|
<div
|
||||||
|
ref="frozenPaneRef"
|
||||||
|
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
|
||||||
|
:style="{ width: FROZEN_WIDTH + 'px' }"
|
||||||
|
@scroll="handleFrozenScroll"
|
||||||
|
>
|
||||||
|
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
||||||
|
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
|
Task Type / Task
|
||||||
|
</div>
|
||||||
|
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
||||||
|
Task Status
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="group in rowGroups" :key="group.key">
|
||||||
|
<div
|
||||||
|
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
||||||
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
|
@click="toggleGroup(group.key)"
|
||||||
|
>
|
||||||
|
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
|
<component
|
||||||
|
:is="isCollapsed(group.key) ? ChevronRight : ChevronDown"
|
||||||
|
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ group.label }}</span>
|
||||||
|
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!isCollapsed(group.key)">
|
||||||
|
<div
|
||||||
|
v-for="task in group.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="group flex items-center border-b hover:bg-muted/30"
|
||||||
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
||||||
|
>
|
||||||
|
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
||||||
|
{{ taskRowLabel(task) }}
|
||||||
|
</div>
|
||||||
|
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
||||||
|
<EditableTaskStatus
|
||||||
|
:task-id="task.id"
|
||||||
|
:status="task.status"
|
||||||
|
:project-id="projectId"
|
||||||
|
show-assignee
|
||||||
|
:assigned-user-id="task.assigned_user_id"
|
||||||
|
@status-updated="handleStatusUpdated"
|
||||||
|
@assignment-updated="handleAssignmentUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline pane: date axis + bars, scrolls both ways -->
|
||||||
|
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
|
||||||
|
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
|
||||||
|
<!-- Date axis header: month row on top, date-number row below -->
|
||||||
|
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
||||||
|
<div class="relative h-5 border-b">
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Weekend shading -->
|
||||||
|
<div
|
||||||
|
v-for="col in weekendColumns"
|
||||||
|
:key="col.left"
|
||||||
|
class="absolute top-0 bottom-0 pointer-events-none"
|
||||||
|
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Rows -->
|
||||||
|
<div v-for="group in rowGroups" :key="group.key">
|
||||||
|
<div
|
||||||
|
class="relative border-b bg-muted/40 cursor-pointer"
|
||||||
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
|
@click="toggleGroup(group.key)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="group.barLeft !== null"
|
||||||
|
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
||||||
|
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!isCollapsed(group.key)">
|
||||||
|
<div
|
||||||
|
v-for="task in group.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="relative border-b hover:bg-muted/30"
|
||||||
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
|
||||||
|
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
|
||||||
|
:style="taskBarStyle(task)"
|
||||||
|
:title="taskBarTitle(task)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
|
||||||
|
style="right: 6px;"
|
||||||
|
@mousedown="startDrag($event, task, 'move')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.2);"
|
||||||
|
@mousedown="startDrag($event, task, 'resize-start')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.2);"
|
||||||
|
@mousedown="startDrag($event, task, 'resize-end')"
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
|
||||||
|
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||||
|
>
|
||||||
|
{{ taskRowLabel(task) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Submission date markers -->
|
||||||
|
<div
|
||||||
|
v-for="date in submissionDatesFor(task.id)"
|
||||||
|
:key="date"
|
||||||
|
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
|
||||||
|
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
|
||||||
|
:title="`Submitted ${formatDate(date)}`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today marker -->
|
||||||
|
<div
|
||||||
|
v-if="todayLeft !== null"
|
||||||
|
class="absolute top-0 bottom-0 w-px pointer-events-none"
|
||||||
|
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
|
||||||
|
>
|
||||||
|
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||||
|
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
|
||||||
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
import { useDetailPanel } from '@/composables/useDetailPanel'
|
||||||
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
|
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 submissionDates = ref<SubmissionDateInfo[]>([])
|
||||||
|
const episodeFilter = ref<number | null>(null)
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const LABEL_COLUMN_WIDTH = 224 // matches w-56
|
||||||
|
const STATUS_COLUMN_WIDTH = 200 // status select (130px) + assignee avatar button, with padding
|
||||||
|
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
|
||||||
|
const HEADER_HEIGHT = 44
|
||||||
|
const GROUP_ROW_HEIGHT = 32
|
||||||
|
const TASK_ROW_HEIGHT = 36
|
||||||
|
|
||||||
|
// The frozen (Task Type/Task Status) pane and the timeline pane are two
|
||||||
|
// independently-scrolled elements so the horizontal scrollbar only ever
|
||||||
|
// spans the timeline. Vertical scroll position is kept in sync between them.
|
||||||
|
const frozenPaneRef = ref<HTMLElement | null>(null)
|
||||||
|
const timelinePaneRef = ref<HTMLElement | null>(null)
|
||||||
|
let syncingScroll = false
|
||||||
|
|
||||||
|
function handleFrozenScroll() {
|
||||||
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
||||||
|
syncingScroll = true
|
||||||
|
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
|
||||||
|
syncingScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimelineScroll() {
|
||||||
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
||||||
|
syncingScroll = true
|
||||||
|
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
|
||||||
|
syncingScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCALES = ['day', 'week', 'month'] as const
|
||||||
|
type ViewScale = typeof SCALES[number]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubmissionDates() {
|
||||||
|
try {
|
||||||
|
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load submission dates:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
|
||||||
|
const submissionDatesByTask = computed(() => {
|
||||||
|
const map = new Map<number, string[]>()
|
||||||
|
for (const s of submissionDates.value) {
|
||||||
|
const day = s.submitted_at.slice(0, 10)
|
||||||
|
const existing = map.get(s.task_id)
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.includes(day)) existing.push(day)
|
||||||
|
} else {
|
||||||
|
map.set(s.task_id, [day])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function submissionDatesFor(taskId: number): string[] {
|
||||||
|
return submissionDatesByTask.value.get(taskId) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeOptions = computed(() => {
|
||||||
|
const map = new Map<number, string>()
|
||||||
|
for (const t of tasks.value) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
|
||||||
|
type GroupBy = typeof GROUP_BY_OPTIONS[number]
|
||||||
|
const groupBy = ref<GroupBy>('taskType')
|
||||||
|
|
||||||
|
function groupByLabel(mode: GroupBy): string {
|
||||||
|
return mode === 'taskType' ? 'Task Type' : 'Shot'
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityName(task: TaskListItem): string {
|
||||||
|
return task.shot_name || task.asset_name || task.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row label shows whichever dimension ISN'T already the group header:
|
||||||
|
// task type when grouped by shot, and the shot/asset name when grouped by task type.
|
||||||
|
function taskRowLabel(task: TaskListItem): string {
|
||||||
|
return groupBy.value === 'taskType' ? entityName(task) : formatTaskType(task.task_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
tasks: TaskListItem[]
|
||||||
|
barLeft: number | null
|
||||||
|
barWidth: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowGroups = computed<RowGroup[]>(() => {
|
||||||
|
const byKey = new Map<string, TaskListItem[]>()
|
||||||
|
for (const t of scheduledTasks.value) {
|
||||||
|
const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
|
||||||
|
if (!byKey.has(key)) byKey.set(key, [])
|
||||||
|
byKey.get(key)!.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([key, groupTasks]) => {
|
||||||
|
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
|
||||||
|
let barLeft: number | null = null
|
||||||
|
let barWidth: number | null = null
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
const label = groupBy.value === 'taskType' ? formatTaskType(key) : key
|
||||||
|
return { key, label, tasks: sorted, barLeft, barWidth }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function isCollapsed(key: string): boolean {
|
||||||
|
return collapsedGroups.value.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(key: string) {
|
||||||
|
const next = new Set(collapsedGroups.value)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
collapsedGroups.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
|
||||||
|
|
||||||
|
function toggleAllGroups() {
|
||||||
|
collapsedGroups.value = hasCollapsedGroups.value
|
||||||
|
? new Set()
|
||||||
|
: new Set(rowGroups.value.map(g => g.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(groupBy, () => {
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusColor(task: TaskListItem): string {
|
||||||
|
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
|
||||||
|
return status?.color || '#94A3B8'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskActive(task: TaskListItem): boolean {
|
||||||
|
return selectedTask.value?.id === task.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusUpdated(taskId: number, newStatus: string) {
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (task) task.status = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssignmentUpdated(taskId: number, userId: number | null) {
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (task) task.assigned_user_id = userId ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag to reschedule ---
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
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()
|
||||||
|
loadSubmissionDates()
|
||||||
|
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 = ''
|
||||||
|
groupBy.value = 'taskType'
|
||||||
|
loadTasks()
|
||||||
|
loadSubmissionDates()
|
||||||
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
|
||||||
|
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold">Custom Task Types</h3>
|
<h3 class="text-lg font-semibold">Task Types</h3>
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
Add custom task types beyond the standard types to adapt the pipeline to your project needs
|
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,33 +30,30 @@
|
|||||||
|
|
||||||
<!-- Asset Task Types List -->
|
<!-- Asset Task Types List -->
|
||||||
<div class="border rounded-lg divide-y">
|
<div class="border rounded-lg divide-y">
|
||||||
<template v-if="assetTaskTypes.length > 0">
|
<template v-if="assetTaskTypeRows.length > 0">
|
||||||
<div
|
<div
|
||||||
v-for="taskType in assetTaskTypes"
|
v-for="row in assetTaskTypeRows"
|
||||||
:key="taskType"
|
:key="row.name"
|
||||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
|
||||||
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
|
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
|
||||||
Standard
|
<Badge v-else variant="outline">Custom</Badge>
|
||||||
</Badge>
|
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
|
||||||
<Badge v-else variant="outline">
|
|
||||||
Custom
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
|
<div v-if="!row.isStandard" class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="openEditDialog('asset', taskType)"
|
@click="openEditDialog('asset', row.name, row.department)"
|
||||||
>
|
>
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="handleDelete('asset', taskType)"
|
@click="handleDelete('asset', row.name, row.department)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,33 +83,30 @@
|
|||||||
|
|
||||||
<!-- Shot Task Types List -->
|
<!-- Shot Task Types List -->
|
||||||
<div class="border rounded-lg divide-y">
|
<div class="border rounded-lg divide-y">
|
||||||
<template v-if="shotTaskTypes.length > 0">
|
<template v-if="shotTaskTypeRows.length > 0">
|
||||||
<div
|
<div
|
||||||
v-for="taskType in shotTaskTypes"
|
v-for="row in shotTaskTypeRows"
|
||||||
:key="taskType"
|
:key="row.name"
|
||||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
|
||||||
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
|
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
|
||||||
Standard
|
<Badge v-else variant="outline">Custom</Badge>
|
||||||
</Badge>
|
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
|
||||||
<Badge v-else variant="outline">
|
|
||||||
Custom
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
|
<div v-if="!row.isStandard" class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="openEditDialog('shot', taskType)"
|
@click="openEditDialog('shot', row.name, row.department)"
|
||||||
>
|
>
|
||||||
<Pencil class="h-4 w-4" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="handleDelete('shot', taskType)"
|
@click="handleDelete('shot', row.name, row.department)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -155,7 +149,25 @@
|
|||||||
{{ validationError }}
|
{{ validationError }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm text-muted-foreground">
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
3-50 characters, lowercase alphanumeric with underscores only
|
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dialogMode === 'add'" class="space-y-2">
|
||||||
|
<Label>Department (optional)</Label>
|
||||||
|
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="None (general purpose)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None (general purpose)</SelectItem>
|
||||||
|
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
|
||||||
|
{{ formatTaskTypeName(department.name) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +193,7 @@
|
|||||||
// Only clear values when dialog closes and we're not in the middle of deleting
|
// Only clear values when dialog closes and we're not in the middle of deleting
|
||||||
taskTypeToDelete = ''
|
taskTypeToDelete = ''
|
||||||
categoryToDelete = ''
|
categoryToDelete = ''
|
||||||
|
departmentToDelete = ''
|
||||||
deleteError = ''
|
deleteError = ''
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -237,6 +251,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
|
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
|
||||||
|
import { departmentService, type DepartmentInfo } from '@/services/department'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -250,6 +266,13 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
|
|
||||||
|
interface TaskTypeRow {
|
||||||
|
name: string
|
||||||
|
isStandard: boolean
|
||||||
|
department?: string
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
|
|||||||
const isDialogOpen = ref(false)
|
const isDialogOpen = ref(false)
|
||||||
const dialogMode = ref<'add' | 'edit'>('add')
|
const dialogMode = ref<'add' | 'edit'>('add')
|
||||||
const dialogCategory = ref<'asset' | 'shot'>('asset')
|
const dialogCategory = ref<'asset' | 'shot'>('asset')
|
||||||
|
const dialogDepartment = ref('')
|
||||||
|
const editingDepartment = ref('')
|
||||||
const taskTypeName = ref('')
|
const taskTypeName = ref('')
|
||||||
const originalTaskTypeName = ref('')
|
const originalTaskTypeName = ref('')
|
||||||
const validationError = ref('')
|
const validationError = ref('')
|
||||||
@@ -268,6 +293,7 @@ const isSaving = ref(false)
|
|||||||
const isDeleteDialogOpen = ref(false)
|
const isDeleteDialogOpen = ref(false)
|
||||||
const taskTypeToDelete = ref('')
|
const taskTypeToDelete = ref('')
|
||||||
const categoryToDelete = ref<'asset' | 'shot'>('asset')
|
const categoryToDelete = ref<'asset' | 'shot'>('asset')
|
||||||
|
const departmentToDelete = ref('')
|
||||||
const deleteError = ref('')
|
const deleteError = ref('')
|
||||||
const isDeleting = ref(false)
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
|
|||||||
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
|
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
|
||||||
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
|
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
|
||||||
|
|
||||||
|
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
|
||||||
|
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
|
||||||
|
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
|
||||||
|
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
|
||||||
|
const dialogCustomDepartments = computed(() => {
|
||||||
|
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
|
||||||
|
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
|
||||||
|
const rows = new Map<string, TaskTypeRow>()
|
||||||
|
for (const t of flatAll) {
|
||||||
|
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
|
||||||
|
}
|
||||||
|
for (const department of departments) {
|
||||||
|
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
|
||||||
|
for (const t of department.task_types) {
|
||||||
|
const existing = rows.get(t)
|
||||||
|
if (existing) {
|
||||||
|
existing.department = department.name
|
||||||
|
existing.isStandard = existing.isStandard || isDeptStandard
|
||||||
|
} else {
|
||||||
|
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(rows.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
|
||||||
|
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
|
||||||
|
|
||||||
const isTaskTypeNameValid = computed(() => {
|
const isTaskTypeNameValid = computed(() => {
|
||||||
return taskTypeName.value.length >= 3 && !validationError.value
|
const minLength = dialogDepartment.value ? 2 : 3
|
||||||
|
return taskTypeName.value.length >= minLength && !validationError.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadTaskTypes = async () => {
|
const loadTaskTypes = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
const [types] = await Promise.all([
|
||||||
|
customTaskTypeService.getAllTaskTypes(props.projectId),
|
||||||
|
departmentsStore.fetchProjectDepartments(props.projectId, true)
|
||||||
|
])
|
||||||
|
taskTypes.value = types
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load task types:', error)
|
console.error('Failed to load task types:', error)
|
||||||
toast({
|
toast({
|
||||||
@@ -298,28 +361,21 @@ const loadTaskTypes = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStandardAssetType = (taskType: string): boolean => {
|
|
||||||
return standardAssetTypes.value.includes(taskType)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStandardShotType = (taskType: string): boolean => {
|
|
||||||
return standardShotTypes.value.includes(taskType)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTaskTypeName = (taskType: string): string => {
|
const formatTaskTypeName = (taskType: string): string => {
|
||||||
return taskType.replace(/_/g, ' ')
|
return taskType.replace(/_/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateTaskTypeName = () => {
|
const validateTaskTypeName = () => {
|
||||||
const name = taskTypeName.value.trim()
|
const name = taskTypeName.value.trim()
|
||||||
|
const minLength = dialogDepartment.value ? 2 : 3
|
||||||
|
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
validationError.value = ''
|
validationError.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.length < 3) {
|
if (name.length < minLength) {
|
||||||
validationError.value = 'Task type name must be at least 3 characters'
|
validationError.value = `Task type name must be at least ${minLength} characters`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,10 +389,10 @@ const validateTaskTypeName = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicates (only if adding or changing name)
|
// Check for duplicates against the merged (flat + department) rows for this category
|
||||||
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
|
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
|
||||||
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
|
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
|
||||||
if (existingTypes.includes(name)) {
|
if (existingRows.some(row => row.name === name)) {
|
||||||
validationError.value = 'A task type with this name already exists'
|
validationError.value = 'A task type with this name already exists'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -348,15 +404,17 @@ const validateTaskTypeName = () => {
|
|||||||
const openAddDialog = (category: 'asset' | 'shot') => {
|
const openAddDialog = (category: 'asset' | 'shot') => {
|
||||||
dialogMode.value = 'add'
|
dialogMode.value = 'add'
|
||||||
dialogCategory.value = category
|
dialogCategory.value = category
|
||||||
|
dialogDepartment.value = ''
|
||||||
taskTypeName.value = ''
|
taskTypeName.value = ''
|
||||||
originalTaskTypeName.value = ''
|
originalTaskTypeName.value = ''
|
||||||
validationError.value = ''
|
validationError.value = ''
|
||||||
isDialogOpen.value = true
|
isDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
|
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
dialogCategory.value = category
|
dialogCategory.value = category
|
||||||
|
editingDepartment.value = department || ''
|
||||||
taskTypeName.value = taskType
|
taskTypeName.value = taskType
|
||||||
originalTaskTypeName.value = taskType
|
originalTaskTypeName.value = taskType
|
||||||
validationError.value = ''
|
validationError.value = ''
|
||||||
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
|
|||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
isDialogOpen.value = false
|
isDialogOpen.value = false
|
||||||
|
dialogDepartment.value = ''
|
||||||
|
editingDepartment.value = ''
|
||||||
taskTypeName.value = ''
|
taskTypeName.value = ''
|
||||||
originalTaskTypeName.value = ''
|
originalTaskTypeName.value = ''
|
||||||
validationError.value = ''
|
validationError.value = ''
|
||||||
@@ -381,17 +441,30 @@ const handleDialogSave = async () => {
|
|||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
|
|
||||||
if (dialogMode.value === 'add') {
|
if (dialogMode.value === 'add') {
|
||||||
|
if (dialogDepartment.value) {
|
||||||
|
const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
} else {
|
||||||
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
|
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
|
||||||
task_type: taskTypeName.value.trim(),
|
task_type: taskTypeName.value.trim(),
|
||||||
category: dialogCategory.value
|
category: dialogCategory.value
|
||||||
})
|
})
|
||||||
console.log('Add task type response:', response)
|
|
||||||
taskTypes.value = response
|
taskTypes.value = response
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: `Task type "${taskTypeName.value}" added successfully`
|
description: `Task type "${taskTypeName.value}" added successfully`
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
if (editingDepartment.value) {
|
||||||
|
const response = await departmentService.renameDepartmentTaskType(
|
||||||
|
props.projectId,
|
||||||
|
editingDepartment.value,
|
||||||
|
originalTaskTypeName.value,
|
||||||
|
taskTypeName.value.trim()
|
||||||
|
)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
} else {
|
} else {
|
||||||
const response = await customTaskTypeService.updateCustomTaskType(
|
const response = await customTaskTypeService.updateCustomTaskType(
|
||||||
props.projectId,
|
props.projectId,
|
||||||
@@ -402,8 +475,8 @@ const handleDialogSave = async () => {
|
|||||||
category: dialogCategory.value
|
category: dialogCategory.value
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
console.log('Update task type response:', response)
|
|
||||||
taskTypes.value = response
|
taskTypes.value = response
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
@@ -427,9 +500,10 @@ const handleDialogSave = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
|
const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
|
||||||
taskTypeToDelete.value = taskType
|
taskTypeToDelete.value = taskType
|
||||||
categoryToDelete.value = category
|
categoryToDelete.value = category
|
||||||
|
departmentToDelete.value = department || ''
|
||||||
deleteError.value = ''
|
deleteError.value = ''
|
||||||
isDeleteDialogOpen.value = true
|
isDeleteDialogOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
|
|||||||
// Capture values immediately before any async operations
|
// Capture values immediately before any async operations
|
||||||
const taskTypeToDeleteLocal = taskTypeToDelete.value
|
const taskTypeToDeleteLocal = taskTypeToDelete.value
|
||||||
const categoryToDeleteLocal = categoryToDelete.value
|
const categoryToDeleteLocal = categoryToDelete.value
|
||||||
|
const departmentToDeleteLocal = departmentToDelete.value
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDeleting.value = true
|
isDeleting.value = true
|
||||||
@@ -454,12 +529,17 @@ const confirmDelete = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (departmentToDeleteLocal) {
|
||||||
|
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
} else {
|
||||||
const response = await customTaskTypeService.deleteCustomTaskType(
|
const response = await customTaskTypeService.deleteCustomTaskType(
|
||||||
props.projectId,
|
props.projectId,
|
||||||
taskTypeToDeleteLocal,
|
taskTypeToDeleteLocal,
|
||||||
categoryToDeleteLocal
|
categoryToDeleteLocal
|
||||||
)
|
)
|
||||||
taskTypes.value = response
|
taskTypes.value = response
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
@@ -471,16 +551,20 @@ const confirmDelete = async () => {
|
|||||||
// Close dialog and clear values
|
// Close dialog and clear values
|
||||||
isDeleteDialogOpen.value = false
|
isDeleteDialogOpen.value = false
|
||||||
taskTypeToDelete.value = ''
|
taskTypeToDelete.value = ''
|
||||||
categoryToDelete.value = ''
|
categoryToDelete.value = 'asset'
|
||||||
|
departmentToDelete.value = ''
|
||||||
deleteError.value = ''
|
deleteError.value = ''
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to delete task type:', error)
|
console.error('Failed to delete task type:', error)
|
||||||
const errorData = error.response?.data
|
const errorData = error.response?.data
|
||||||
|
const detail = errorData?.detail
|
||||||
|
|
||||||
if (errorData?.task_count) {
|
if (detail?.task_count) {
|
||||||
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
|
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
|
||||||
|
} else if (typeof detail === 'string') {
|
||||||
|
deleteError.value = detail
|
||||||
} else {
|
} else {
|
||||||
deleteError.value = errorData?.detail || 'Failed to delete task type'
|
deleteError.value = 'Failed to delete task type'
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Departments</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Users class="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h4 class="font-semibold">All Departments</h4>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" @click="openAddDialog">
|
||||||
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
|
Add Department
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg divide-y">
|
||||||
|
<template v-if="allDepartments.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="department in allDepartments"
|
||||||
|
:key="department.name"
|
||||||
|
class="p-3 hover:bg-muted/50 transition-colors space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium capitalize">{{ formatName(department.name) }}</span>
|
||||||
|
<Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
|
||||||
|
<Badge v-else variant="outline">Custom</Badge>
|
||||||
|
<Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" @click="handleDelete(department.name)">
|
||||||
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5 pl-1">
|
||||||
|
<Badge
|
||||||
|
v-for="taskType in department.task_types"
|
||||||
|
:key="taskType"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs font-normal capitalize gap-1"
|
||||||
|
>
|
||||||
|
{{ formatName(taskType) }}
|
||||||
|
<button
|
||||||
|
v-if="!isStandardDepartment(department.name)"
|
||||||
|
class="hover:text-destructive"
|
||||||
|
@click="handleDeleteTaskType(department.name, taskType)"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
<span v-if="department.task_types.length === 0" class="text-xs text-muted-foreground">
|
||||||
|
No task types defined
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="!isStandardDepartment(department.name)"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
@click="openAddTaskTypeDialog(department.name)"
|
||||||
|
>
|
||||||
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
|
Add Task Type
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No departments defined
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Department Dialog -->
|
||||||
|
<Dialog :open="isDialogOpen" @update:open="closeDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ dialogMode === 'add'
|
||||||
|
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
|
||||||
|
: 'Update the department name. This will update all team members and tasks using this department.'
|
||||||
|
}}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="departmentName">Department Name</Label>
|
||||||
|
<Input
|
||||||
|
id="departmentName"
|
||||||
|
v-model="departmentName"
|
||||||
|
placeholder="e.g., fx, previz, matchmove"
|
||||||
|
:class="{ 'border-destructive': validationError }"
|
||||||
|
@input="validateDepartmentName"
|
||||||
|
/>
|
||||||
|
<p v-if="validationError" class="text-sm text-destructive">
|
||||||
|
{{ validationError }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
|
2-50 characters, lowercase alphanumeric with underscores only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dialogMode === 'add'" class="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select v-model="departmentType">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="shot">Shot</SelectItem>
|
||||||
|
<SelectItem value="asset">Asset</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Shot departments apply to shot tasks; asset departments apply to asset tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="closeDialog">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
|
||||||
|
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Add Task Type Dialog -->
|
||||||
|
<Dialog :open="isTaskTypeDialogOpen" @update:open="closeTaskTypeDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Task Type to "{{ formatName(taskTypeDepartment) }}"</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="taskTypeName">Task Type Name</Label>
|
||||||
|
<Input
|
||||||
|
id="taskTypeName"
|
||||||
|
v-model="taskTypeName"
|
||||||
|
placeholder="e.g., blocking, first_pass"
|
||||||
|
:class="{ 'border-destructive': taskTypeValidationError }"
|
||||||
|
@input="validateTaskTypeName"
|
||||||
|
/>
|
||||||
|
<p v-if="taskTypeValidationError" class="text-sm text-destructive">
|
||||||
|
{{ taskTypeValidationError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="closeTaskTypeDialog">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleAddTaskType" :disabled="!isTaskTypeNameValid || isSavingTaskType">
|
||||||
|
<div v-if="isSavingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Department Confirmation Dialog -->
|
||||||
|
<AlertDialog
|
||||||
|
:open="isDeleteDialogOpen"
|
||||||
|
@update:open="(open) => {
|
||||||
|
isDeleteDialogOpen = open
|
||||||
|
if (!open && !isDeleting) {
|
||||||
|
departmentToDelete = ''
|
||||||
|
deleteError = ''
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Department</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the department "{{ departmentToDelete }}"?
|
||||||
|
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
|
||||||
|
{{ deleteError }}
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
@click="confirmDelete"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
class="bg-destructive hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<!-- Delete Task Type Confirmation Dialog -->
|
||||||
|
<AlertDialog
|
||||||
|
:open="isDeleteTaskTypeDialogOpen"
|
||||||
|
@update:open="(open) => {
|
||||||
|
isDeleteTaskTypeDialogOpen = open
|
||||||
|
if (!open && !isDeletingTaskType) {
|
||||||
|
taskTypeToDelete = null
|
||||||
|
deleteTaskTypeError = ''
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the task type "{{ taskTypeToDelete?.taskType }}" from "{{ taskTypeToDelete?.department }}"?
|
||||||
|
<span v-if="deleteTaskTypeError" class="block mt-2 text-destructive font-medium">
|
||||||
|
{{ deleteTaskTypeError }}
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
@click="confirmDeleteTaskType"
|
||||||
|
:disabled="isDeletingTaskType"
|
||||||
|
class="bg-destructive hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
<div v-if="isDeletingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
|
import { departmentService, type DepartmentType } from '@/services/department'
|
||||||
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
// Department dialog state
|
||||||
|
const isDialogOpen = ref(false)
|
||||||
|
const dialogMode = ref<'add' | 'edit'>('add')
|
||||||
|
const departmentName = ref('')
|
||||||
|
const departmentType = ref<DepartmentType | ''>('')
|
||||||
|
const originalDepartmentName = ref('')
|
||||||
|
const validationError = ref('')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// Task type dialog state
|
||||||
|
const isTaskTypeDialogOpen = ref(false)
|
||||||
|
const taskTypeDepartment = ref('')
|
||||||
|
const taskTypeName = ref('')
|
||||||
|
const taskTypeValidationError = ref('')
|
||||||
|
const isSavingTaskType = ref(false)
|
||||||
|
|
||||||
|
// Delete department dialog state
|
||||||
|
const isDeleteDialogOpen = ref(false)
|
||||||
|
const departmentToDelete = ref('')
|
||||||
|
const deleteError = ref('')
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
|
// Delete task type dialog state
|
||||||
|
const isDeleteTaskTypeDialogOpen = ref(false)
|
||||||
|
const taskTypeToDelete = ref<{ department: string; taskType: string } | null>(null)
|
||||||
|
const deleteTaskTypeError = ref('')
|
||||||
|
const isDeletingTaskType = ref(false)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
|
||||||
|
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
|
||||||
|
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
|
||||||
|
|
||||||
|
const isDepartmentNameValid = computed(() => {
|
||||||
|
return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTaskTypeNameValid = computed(() => {
|
||||||
|
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadDepartments = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
await departmentsStore.fetchProjectDepartments(props.projectId, true)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load departments:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.response?.data?.detail || 'Failed to load departments',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStandardDepartment = (department: string): boolean => {
|
||||||
|
return standardDepartments.value.some(d => d.name === department)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatName = (name: string): string => {
|
||||||
|
return name.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateDepartmentName = () => {
|
||||||
|
const name = departmentName.value.trim()
|
||||||
|
|
||||||
|
if (name.length === 0) {
|
||||||
|
validationError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length < 2) {
|
||||||
|
validationError.value = 'Department name must be at least 2 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 50) {
|
||||||
|
validationError.value = 'Department name must be at most 50 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||||
|
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
|
||||||
|
if (allDepartmentNames.value.includes(name)) {
|
||||||
|
validationError.value = 'A department with this name already exists'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validationError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateTaskTypeName = () => {
|
||||||
|
const name = taskTypeName.value.trim()
|
||||||
|
|
||||||
|
if (name.length === 0) {
|
||||||
|
taskTypeValidationError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length < 2 || name.length > 50) {
|
||||||
|
taskTypeValidationError.value = 'Task type name must be 2-50 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||||
|
taskTypeValidationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = allDepartments.value.find(d => d.name === taskTypeDepartment.value)
|
||||||
|
if (department?.task_types.includes(name)) {
|
||||||
|
taskTypeValidationError.value = 'A task type with this name already exists in this department'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskTypeValidationError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
dialogMode.value = 'add'
|
||||||
|
departmentName.value = ''
|
||||||
|
departmentType.value = ''
|
||||||
|
originalDepartmentName.value = ''
|
||||||
|
validationError.value = ''
|
||||||
|
isDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (department: string) => {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
departmentName.value = department
|
||||||
|
originalDepartmentName.value = department
|
||||||
|
validationError.value = ''
|
||||||
|
isDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
isDialogOpen.value = false
|
||||||
|
departmentName.value = ''
|
||||||
|
departmentType.value = ''
|
||||||
|
originalDepartmentName.value = ''
|
||||||
|
validationError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddTaskTypeDialog = (department: string) => {
|
||||||
|
taskTypeDepartment.value = department
|
||||||
|
taskTypeName.value = ''
|
||||||
|
taskTypeValidationError.value = ''
|
||||||
|
isTaskTypeDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTaskTypeDialog = () => {
|
||||||
|
isTaskTypeDialogOpen.value = false
|
||||||
|
taskTypeDepartment.value = ''
|
||||||
|
taskTypeName.value = ''
|
||||||
|
taskTypeValidationError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogSave = async () => {
|
||||||
|
validateDepartmentName()
|
||||||
|
|
||||||
|
if (!isDepartmentNameValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
if (dialogMode.value === 'add') {
|
||||||
|
const response = await departmentService.addDepartment(props.projectId, {
|
||||||
|
department: departmentName.value.trim(),
|
||||||
|
department_type: departmentType.value as DepartmentType
|
||||||
|
})
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Department "${departmentName.value}" added successfully`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const response = await departmentService.updateDepartment(
|
||||||
|
props.projectId,
|
||||||
|
originalDepartmentName.value,
|
||||||
|
{
|
||||||
|
old_name: originalDepartmentName.value,
|
||||||
|
new_name: departmentName.value.trim()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Department updated successfully`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('updated')
|
||||||
|
closeDialog()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save department:', error)
|
||||||
|
const errorMessage = error.response?.data?.detail || 'Failed to save department'
|
||||||
|
validationError.value = errorMessage
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: errorMessage,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTaskType = async () => {
|
||||||
|
validateTaskTypeName()
|
||||||
|
|
||||||
|
if (!isTaskTypeNameValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSavingTaskType.value = true
|
||||||
|
|
||||||
|
const response = await departmentService.addDepartmentTaskType(
|
||||||
|
props.projectId,
|
||||||
|
taskTypeDepartment.value,
|
||||||
|
taskTypeName.value.trim()
|
||||||
|
)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Task type "${taskTypeName.value}" added successfully`
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('updated')
|
||||||
|
closeTaskTypeDialog()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add task type:', error)
|
||||||
|
const errorMessage = error.response?.data?.detail || 'Failed to add task type'
|
||||||
|
taskTypeValidationError.value = errorMessage
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: errorMessage,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSavingTaskType.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (department: string) => {
|
||||||
|
departmentToDelete.value = department
|
||||||
|
deleteError.value = ''
|
||||||
|
isDeleteDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
const departmentToDeleteLocal = departmentToDelete.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
isDeleting.value = true
|
||||||
|
deleteError.value = ''
|
||||||
|
|
||||||
|
if (!departmentToDeleteLocal) {
|
||||||
|
deleteError.value = 'Department name is missing. Please try again.'
|
||||||
|
isDeleting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Department "${departmentToDeleteLocal}" deleted successfully`
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('updated')
|
||||||
|
|
||||||
|
isDeleteDialogOpen.value = false
|
||||||
|
departmentToDelete.value = ''
|
||||||
|
deleteError.value = ''
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to delete department:', error)
|
||||||
|
const errorData = error.response?.data
|
||||||
|
|
||||||
|
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
|
||||||
|
const { task_count, member_count } = errorData.detail
|
||||||
|
const parts = []
|
||||||
|
if (member_count) parts.push(`${member_count} team member(s)`)
|
||||||
|
if (task_count) parts.push(`${task_count} task(s)`)
|
||||||
|
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
|
||||||
|
} else {
|
||||||
|
deleteError.value = errorData?.detail || 'Failed to delete department'
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: deleteError.value,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTaskType = (department: string, taskType: string) => {
|
||||||
|
taskTypeToDelete.value = { department, taskType }
|
||||||
|
deleteTaskTypeError.value = ''
|
||||||
|
isDeleteTaskTypeDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteTaskType = async () => {
|
||||||
|
const target = taskTypeToDelete.value
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isDeletingTaskType.value = true
|
||||||
|
deleteTaskTypeError.value = ''
|
||||||
|
|
||||||
|
const response = await departmentService.removeDepartmentTaskType(props.projectId, target.department, target.taskType)
|
||||||
|
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Task type "${target.taskType}" deleted successfully`
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('updated')
|
||||||
|
|
||||||
|
isDeleteTaskTypeDialogOpen.value = false
|
||||||
|
taskTypeToDelete.value = null
|
||||||
|
deleteTaskTypeError.value = ''
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to delete task type:', error)
|
||||||
|
const errorData = error.response?.data
|
||||||
|
|
||||||
|
if (errorData?.detail?.task_count !== undefined) {
|
||||||
|
deleteTaskTypeError.value = `Cannot delete: ${errorData.detail.task_count} task(s) are using this task type`
|
||||||
|
} else {
|
||||||
|
deleteTaskTypeError.value = errorData?.detail || 'Failed to delete task type'
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: deleteTaskTypeError.value,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isDeletingTaskType.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadDepartments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="visible"
|
v-if="visible"
|
||||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
class="fixed right-0 top-16 bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Filter / Sort Toolbar -->
|
||||||
|
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Popover v-if="tasks.length > 0">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||||
|
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Tasks
|
||||||
|
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
|
||||||
|
{{ taskFilters.length }}
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-52 p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
<CheckableCommandItem
|
||||||
|
value="all"
|
||||||
|
:model-value="taskFilters.length === 0"
|
||||||
|
@update:model-value="taskFilters = []"
|
||||||
|
>
|
||||||
|
All Tasks
|
||||||
|
</CheckableCommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CheckableCommandItem
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:value="String(task.id)"
|
||||||
|
:model-value="taskFilters.includes(task.id)"
|
||||||
|
@update:model-value="toggleTaskFilter(task.id)"
|
||||||
|
>
|
||||||
|
{{ formatTaskType(task.task_type) }}
|
||||||
|
</CheckableCommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
|
||||||
|
size="icon-sm"
|
||||||
|
@click="showSubmissionNotes = !showSubmissionNotes"
|
||||||
|
>
|
||||||
|
<Send class="h-3.5 w-3.5" />
|
||||||
|
<span class="sr-only">Submission notes</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Submission notes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
:variant="showClientOnly ? 'secondary' : 'outline'"
|
||||||
|
size="icon-sm"
|
||||||
|
@click="showClientOnly = !showClientOnly"
|
||||||
|
>
|
||||||
|
<Megaphone class="h-3.5 w-3.5" />
|
||||||
|
<span class="sr-only">Client notes only</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Client notes only</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select v-model="sortOrder">
|
||||||
|
<SelectTrigger class="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="newest">Newest first</SelectItem>
|
||||||
|
<SelectItem value="oldest">Oldest first</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes History (Top) -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<div v-if="combinedEntries.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
||||||
|
<p class="text-sm">No notes yet. Start the conversation below.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="entry in combinedEntries" :key="entry.id">
|
||||||
|
<!-- Production Note -->
|
||||||
|
<div v-if="entry.kind === 'note'" class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
|
||||||
|
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
||||||
|
</div>
|
||||||
|
<NoteItem
|
||||||
|
:note="entry.note"
|
||||||
|
:task-id="entry.note.task_id"
|
||||||
|
date-format="absolute"
|
||||||
|
hide-client-badge
|
||||||
|
@note-updated="emit('notesUpdated')"
|
||||||
|
@reply="handleReply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submission Note (read-only) -->
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
|
||||||
|
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Avatar class="h-8 w-8 flex-shrink-0">
|
||||||
|
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
|
||||||
|
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-semibold text-sm">
|
||||||
|
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
|
{{ formatDateOnly(entry.submission.submitted_at) }}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Input (Bottom) -->
|
||||||
|
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
|
||||||
|
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
|
||||||
|
<span class="truncate">
|
||||||
|
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
|
||||||
|
<span class="text-muted-foreground">— {{ replyToNote.content }}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
|
||||||
|
<SelectTrigger class="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Select task..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
|
||||||
|
{{ formatTaskType(task.task_type) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div ref="composerRef">
|
||||||
|
<Textarea
|
||||||
|
v-model="newNoteContent"
|
||||||
|
placeholder="Add a note..."
|
||||||
|
rows="2"
|
||||||
|
class="resize-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
|
||||||
|
@click="newNoteType = 'internal'"
|
||||||
|
>
|
||||||
|
Internal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
|
||||||
|
@click="newNoteType = 'client'"
|
||||||
|
>
|
||||||
|
Client
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
|
||||||
|
@click="handleAddNote"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus class="h-4 w-4 mr-2" />
|
||||||
|
Add Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
|
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import NoteItem from '@/components/task/NoteItem.vue'
|
||||||
|
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
|
interface EntityNoteTask {
|
||||||
|
id: number
|
||||||
|
task_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tasks: EntityNoteTask[]
|
||||||
|
notes: ProductionNote[]
|
||||||
|
submissions: Submission[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
notesUpdated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
|
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
|
const taskFilters = ref<number[]>([])
|
||||||
|
const sortOrder = ref<'newest' | 'oldest'>('newest')
|
||||||
|
const showSubmissionNotes = ref(true)
|
||||||
|
const showClientOnly = ref(false)
|
||||||
|
|
||||||
|
const newNoteContent = ref('')
|
||||||
|
const newNoteType = ref<NoteType>('internal')
|
||||||
|
const targetTaskId = ref<number | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const replyToNoteId = ref<number | null>(null)
|
||||||
|
const composerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.tasks, (tasks) => {
|
||||||
|
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
|
||||||
|
targetTaskId.value = tasks[0]?.id ?? null
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function toggleTaskFilter(taskId: number) {
|
||||||
|
const index = taskFilters.value.indexOf(taskId)
|
||||||
|
if (index > -1) taskFilters.value.splice(index, 1)
|
||||||
|
else taskFilters.value.push(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFilterActive(taskId: number): boolean {
|
||||||
|
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskTypeFor(taskId: number): string {
|
||||||
|
return props.tasks.find(t => t.id === taskId)?.task_type || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskType(taskType: string): string {
|
||||||
|
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(firstName: string, lastName: string): string {
|
||||||
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteEntry {
|
||||||
|
kind: 'note'
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
note: ProductionNote
|
||||||
|
}
|
||||||
|
interface SubmissionEntry {
|
||||||
|
kind: 'submission'
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
submission: Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
|
||||||
|
const noteEntries: NoteEntry[] = props.notes
|
||||||
|
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
|
||||||
|
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
|
||||||
|
|
||||||
|
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
|
||||||
|
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
|
||||||
|
? props.submissions
|
||||||
|
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
|
||||||
|
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const direction = sortOrder.value === 'newest' ? -1 : 1
|
||||||
|
return [...noteEntries, ...submissionEntries].sort(
|
||||||
|
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
|
||||||
|
for (const note of notes) {
|
||||||
|
if (note.id === id) return note
|
||||||
|
if (note.child_notes) {
|
||||||
|
const found = findNote(note.child_notes, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyToNote = computed(() => {
|
||||||
|
if (replyToNoteId.value === null) return undefined
|
||||||
|
return findNote(props.notes, replyToNoteId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleReply(noteId: number) {
|
||||||
|
const note = findNote(props.notes, noteId)
|
||||||
|
if (!note) return
|
||||||
|
replyToNoteId.value = noteId
|
||||||
|
targetTaskId.value = note.task_id
|
||||||
|
nextTick(() => {
|
||||||
|
composerRef.value?.querySelector('textarea')?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyToNoteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddNote() {
|
||||||
|
if (!newNoteContent.value.trim() || !targetTaskId.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await taskService.createTaskNote(
|
||||||
|
targetTaskId.value,
|
||||||
|
newNoteContent.value,
|
||||||
|
replyToNoteId.value || undefined,
|
||||||
|
newNoteType.value
|
||||||
|
)
|
||||||
|
newNoteContent.value = ''
|
||||||
|
newNoteType.value = 'internal'
|
||||||
|
replyToNoteId.value = null
|
||||||
|
emit('notesUpdated')
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Note added successfully'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error adding note:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.response?.data?.detail || 'Failed to add note',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
:key="selectedTaskId"
|
:key="selectedTaskId"
|
||||||
:task-id="selectedTaskId"
|
:task-id="selectedTaskId"
|
||||||
:initial-tab="selectedTaskTab"
|
:initial-tab="selectedTaskTab"
|
||||||
|
:initial-reply-note-id="selectedTaskReplyNoteId"
|
||||||
@close="selectedTaskId = null"
|
@close="selectedTaskId = null"
|
||||||
@task-updated="loadShots"
|
@task-updated="loadShots"
|
||||||
/>
|
/>
|
||||||
@@ -848,16 +849,19 @@ const clearSearch = () => {
|
|||||||
|
|
||||||
const selectedTaskId = ref<number | null>(null)
|
const selectedTaskId = ref<number | null>(null)
|
||||||
const selectedTaskTab = ref<string>('infos')
|
const selectedTaskTab = ref<string>('infos')
|
||||||
|
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
|
||||||
selectedTaskId.value = task.id
|
selectedTaskId.value = task.id
|
||||||
selectedTaskTab.value = tab || 'infos'
|
selectedTaskTab.value = tab || 'infos'
|
||||||
|
selectedTaskReplyNoteId.value = noteId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the task sub-panel whenever the shot selection changes (including close)
|
// Reset the task sub-panel whenever the shot selection changes (including close)
|
||||||
watch(selectedShot, () => {
|
watch(selectedShot, () => {
|
||||||
selectedTaskId.value = null
|
selectedTaskId.value = null
|
||||||
selectedTaskTab.value = 'infos'
|
selectedTaskTab.value = 'infos'
|
||||||
|
selectedTaskReplyNoteId.value = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatStatus = (status: ShotStatus) => {
|
const formatStatus = (status: ShotStatus) => {
|
||||||
|
|||||||
@@ -10,16 +10,9 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Shot Details -->
|
<!-- Shot Details -->
|
||||||
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
<div v-else-if="shot" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<template #badges>
|
||||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-2 h-2 rounded-full mr-1"
|
|
||||||
:class="getStatusColor(shot.status)"
|
|
||||||
></div>
|
|
||||||
{{ formatStatus(shot.status) }}
|
|
||||||
</Badge>
|
|
||||||
<!-- Deletion status indicator for admins -->
|
<!-- Deletion status indicator for admins -->
|
||||||
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||||
@@ -28,17 +21,40 @@
|
|||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
<Info class="h-4 w-4" />
|
||||||
<TabsTrigger value="assets">Assets</TabsTrigger>
|
<span class="sr-only">Infos</span>
|
||||||
<TabsTrigger value="references">References</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="design">Design</TabsTrigger>
|
<TabsTrigger value="notes" title="Notes">
|
||||||
|
<span class="relative inline-flex">
|
||||||
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
v-if="shotNotes.length > 0"
|
||||||
|
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">Notes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="assets" title="Assets">
|
||||||
|
<Package class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Assets</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="references" title="References">
|
||||||
|
<Image class="h-4 w-4" />
|
||||||
|
<span class="sr-only">References</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="design" title="Design">
|
||||||
|
<Edit class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Design</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Infos Tab -->
|
<!-- Infos Tab -->
|
||||||
<TabsContent value="infos" class="flex-1 p-6 space-y-6">
|
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
|
||||||
<!-- Shot Information -->
|
<!-- Shot Information -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3 class="text-sm font-semibold">Shot Information</h3>
|
<h3 class="text-sm font-semibold">Shot Information</h3>
|
||||||
@@ -171,73 +187,45 @@
|
|||||||
<p class="text-sm text-muted-foreground">No tasks yet</p>
|
<p class="text-sm text-muted-foreground">No tasks yet</p>
|
||||||
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
|
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tasks Table -->
|
<!-- Tasks Cards -->
|
||||||
<div v-else class="border rounded-lg overflow-hidden">
|
<div v-else class="space-y-1.5">
|
||||||
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
|
<Card
|
||||||
<div>Task Type</div>
|
|
||||||
<div>Assignee</div>
|
|
||||||
<div>Status</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
|
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
@click="$emit('select-task', task, 'infos')"
|
@click="$emit('select-task', task, 'infos')"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
<span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
|
||||||
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<Avatar class="h-5 w-5 flex-shrink-0" v-if="task.assigned_user_name">
|
<Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
|
||||||
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
|
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
|
||||||
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
|
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span class="truncate">{{ task.assigned_user_name || 'Unassigned' }}</span>
|
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
Loading notes...
|
||||||
<Popover v-if="canCreateNote">
|
|
||||||
<PopoverTrigger as-child>
|
|
||||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
|
||||||
<Plus class="h-3 w-3 mr-1" />
|
|
||||||
Add Note
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent class="w-48 p-2" align="end">
|
|
||||||
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
|
|
||||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
||||||
<Button
|
|
||||||
v-for="task in tasks"
|
|
||||||
:key="task.id"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="$emit('select-task', task, 'notes')"
|
|
||||||
>
|
|
||||||
{{ formatTaskType(task.task_type) }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm text-muted-foreground">No notes yet</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<EntityNotes
|
||||||
|
v-else
|
||||||
|
:key="shotId"
|
||||||
|
:tasks="tasks"
|
||||||
|
:notes="shotNotes"
|
||||||
|
:submissions="shotSubmissions"
|
||||||
|
@notes-updated="loadShotNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Assets Tab -->
|
<!-- Assets Tab -->
|
||||||
<TabsContent value="assets" class="flex-1 p-6">
|
<TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-semibold">Linked Assets</h3>
|
<h3 class="text-sm font-semibold">Linked Assets</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -259,7 +247,7 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- References Tab -->
|
<!-- References Tab -->
|
||||||
<TabsContent value="references" class="flex-1 p-6">
|
<TabsContent value="references" class="flex-1 overflow-y-auto p-6 m-0">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||||
<Popover v-if="canUploadReferences">
|
<Popover v-if="canUploadReferences">
|
||||||
@@ -295,7 +283,7 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Design Tab -->
|
<!-- Design Tab -->
|
||||||
<TabsContent value="design" class="flex-1 p-6">
|
<TabsContent value="design" class="flex-1 overflow-y-auto p-6 m-0">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-semibold">Design Information</h3>
|
<h3 class="text-sm font-semibold">Design Information</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -334,10 +322,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
|
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
@@ -346,11 +335,13 @@ import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
|
|
||||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
|
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
import { usePermission } from '@/composables/usePermission'
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
|
||||||
@@ -375,7 +366,7 @@ interface Props {
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'edit', shot: Shot): void
|
(e: 'edit', shot: Shot): void
|
||||||
(e: 'delete', shot: Shot): void
|
(e: 'delete', shot: Shot): void
|
||||||
(e: 'select-task', task: Task, tab?: string): void
|
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
|
||||||
(e: 'link-asset'): void
|
(e: 'link-asset'): void
|
||||||
(e: 'edit-design'): void
|
(e: 'edit-design'): void
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
@@ -385,6 +376,7 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
const { getAvatarUrl } = useAvatarUrl()
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
@@ -395,6 +387,9 @@ const isLoading = ref(false)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isCreatingTask = ref(false)
|
const isCreatingTask = ref(false)
|
||||||
const projectMembers = ref<ProjectMember[]>([])
|
const projectMembers = ref<ProjectMember[]>([])
|
||||||
|
const shotNotes = ref<ProductionNote[]>([])
|
||||||
|
const shotSubmissions = ref<Submission[]>([])
|
||||||
|
const isLoadingNotes = ref(false)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const frameCount = computed(() => {
|
const frameCount = computed(() => {
|
||||||
@@ -434,8 +429,6 @@ const taskStatusCounts = computed(() => {
|
|||||||
|
|
||||||
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
|
|
||||||
|
|
||||||
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
const canUploadReferences = computed(() => {
|
const canUploadReferences = computed(() => {
|
||||||
@@ -444,9 +437,15 @@ const canUploadReferences = computed(() => {
|
|||||||
|
|
||||||
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
|
// Task types offered in "Add Task" include the flat shot task type list
|
||||||
|
// plus every task type owned by a shot department (e.g. Animation's own
|
||||||
|
// task types), deduped against types the shot already has a task for.
|
||||||
const availableTaskTypes = computed(() => {
|
const availableTaskTypes = computed(() => {
|
||||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'shot')
|
||||||
|
.flatMap(d => d.task_types)
|
||||||
|
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
|
||||||
|
return merged.filter(type => !existingTypes.has(type))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
@@ -455,11 +454,13 @@ const loadShotDetails = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
|
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
|
||||||
|
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
taskStatusesStore.fetchProjectStatuses(props.projectId),
|
taskStatusesStore.fetchProjectStatuses(props.projectId),
|
||||||
loadProjectMembers()
|
loadProjectMembers()
|
||||||
])
|
])
|
||||||
loadTasks() // No longer async - uses embedded data
|
loadTasks() // No longer async - uses embedded data
|
||||||
|
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
||||||
console.error('Failed to load shot details:', err)
|
console.error('Failed to load shot details:', err)
|
||||||
@@ -468,6 +469,27 @@ const loadShotDetails = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadShotNotes = async () => {
|
||||||
|
if (tasks.value.length === 0) {
|
||||||
|
shotNotes.value = []
|
||||||
|
shotSubmissions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isLoadingNotes.value = true
|
||||||
|
const [notesByTask, submissionsByTask] = await Promise.all([
|
||||||
|
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
||||||
|
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
||||||
|
])
|
||||||
|
shotNotes.value = notesByTask.flat()
|
||||||
|
shotSubmissions.value = submissionsByTask.flat()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load shot notes:', err)
|
||||||
|
} finally {
|
||||||
|
isLoadingNotes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadProjectMembers = async () => {
|
const loadProjectMembers = async () => {
|
||||||
try {
|
try {
|
||||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||||
@@ -519,12 +541,6 @@ const handleAddTask = async (taskType: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatStatus = (status: ShotStatus) => {
|
|
||||||
return status.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTaskType = (taskType: string) => {
|
const formatTaskType = (taskType: string) => {
|
||||||
return taskType.split('_').map(word =>
|
return taskType.split('_').map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
@@ -537,40 +553,6 @@ const getTaskAssigneeInitials = (task: Task) => {
|
|||||||
return (first + last).toUpperCase()
|
return (first + last).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (status: ShotStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ShotStatus.NOT_STARTED:
|
|
||||||
return 'secondary'
|
|
||||||
case ShotStatus.IN_PROGRESS:
|
|
||||||
return 'default'
|
|
||||||
case ShotStatus.ON_HOLD:
|
|
||||||
return 'outline'
|
|
||||||
case ShotStatus.COMPLETED:
|
|
||||||
return 'default'
|
|
||||||
case ShotStatus.APPROVED:
|
|
||||||
return 'default'
|
|
||||||
default:
|
|
||||||
return 'secondary'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: ShotStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ShotStatus.NOT_STARTED:
|
|
||||||
return 'bg-gray-400'
|
|
||||||
case ShotStatus.IN_PROGRESS:
|
|
||||||
return 'bg-blue-500'
|
|
||||||
case ShotStatus.ON_HOLD:
|
|
||||||
return 'bg-yellow-500'
|
|
||||||
case ShotStatus.COMPLETED:
|
|
||||||
return 'bg-green-500'
|
|
||||||
case ShotStatus.APPROVED:
|
|
||||||
return 'bg-emerald-600'
|
|
||||||
default:
|
|
||||||
return 'bg-gray-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative"
|
<div class="relative flex items-center gap-1"
|
||||||
|
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@@ -37,6 +37,112 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<!-- User Assignment Button -->
|
||||||
|
<div v-if="showAssignee" @click.stop>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 p-0 hover:bg-accent relative"
|
||||||
|
:disabled="isUpdating"
|
||||||
|
@click.stop="ensureMembersLoaded"
|
||||||
|
>
|
||||||
|
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||||
|
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||||
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<User class="h-3 w-3" v-else />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||||
|
|
||||||
|
<!-- Current Assignment Display with X button -->
|
||||||
|
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
|
||||||
|
<Avatar class="h-8 w-8">
|
||||||
|
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||||
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground">Current</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
@click.stop="handleAssignUser(null)"
|
||||||
|
:disabled="isAssigning"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search members..."
|
||||||
|
class="pl-7 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||||
|
<span class="ml-2 text-sm">Loading members...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||||
|
No project members found
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-2"
|
||||||
|
@click="loadProjectMembers"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content when members are loaded -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Project members list -->
|
||||||
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
|
||||||
|
No matching members found
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
v-for="member in filteredProjectMembers"
|
||||||
|
:key="member.user_id"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="w-full justify-start h-10"
|
||||||
|
@click="handleAssignUser(member.user_id)"
|
||||||
|
:disabled="isAssigning"
|
||||||
|
>
|
||||||
|
<Avatar class="h-8 w-8 mr-2">
|
||||||
|
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||||
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||||
|
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||||
|
</div>
|
||||||
|
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading indicator -->
|
<!-- Loading indicator -->
|
||||||
<div
|
<div
|
||||||
v-if="isUpdating || isLoadingStatuses"
|
v-if="isUpdating || isLoadingStatuses"
|
||||||
@@ -56,11 +162,23 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { User, Search, Check, X } from 'lucide-vue-next'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/asset'
|
import { TaskStatus } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||||
|
import type { ProjectMember } from '@/services/project'
|
||||||
|
|
||||||
interface StatusOption {
|
interface StatusOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -73,10 +191,13 @@ interface Props {
|
|||||||
taskId: number
|
taskId: number
|
||||||
status: TaskStatus | string
|
status: TaskStatus | string
|
||||||
projectId: number
|
projectId: number
|
||||||
|
showAssignee?: boolean
|
||||||
|
assignedUserId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'status-updated', taskId: number, newStatus: string): void
|
(e: 'status-updated', taskId: number, newStatus: string): void
|
||||||
|
(e: 'assignment-updated', taskId: number, userId: number | null): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
// Use the shared task statuses store instead of direct API calls
|
// Use the shared task statuses store instead of direct API calls
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const projectMembersStore = useProjectMembersStore()
|
||||||
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false)
|
||||||
|
const isAssigning = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||||
|
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||||
|
|
||||||
|
const filteredProjectMembers = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
return projectMembers.value
|
||||||
|
}
|
||||||
|
const query = searchQuery.value.toLowerCase().trim()
|
||||||
|
return projectMembers.value.filter(member => {
|
||||||
|
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
|
||||||
|
const departmentRole = member.department_role?.toLowerCase() || ''
|
||||||
|
return fullName.includes(query) || departmentRole.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignedUserId = computed(() => props.assignedUserId)
|
||||||
|
const assignedUser = computed(() => {
|
||||||
|
if (!assignedUserId.value) return null
|
||||||
|
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDepartmentRole = (role: string): string => {
|
||||||
|
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserInitials = (member: ProjectMember): string => {
|
||||||
|
const first = member.user_first_name?.charAt(0) || ''
|
||||||
|
const last = member.user_last_name?.charAt(0) || ''
|
||||||
|
return (first + last).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProjectMembers = async () => {
|
||||||
|
try {
|
||||||
|
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load project members:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureMembersLoaded = () => {
|
||||||
|
if (projectMembers.value.length === 0) {
|
||||||
|
loadProjectMembers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignUser = async (userId: number | null) => {
|
||||||
|
isAssigning.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (userId) {
|
||||||
|
await taskService.assignTask(props.taskId, userId)
|
||||||
|
} else {
|
||||||
|
await taskService.updateTask(props.taskId, { assigned_user_id: 0 })
|
||||||
|
}
|
||||||
|
emit('assignment-updated', props.taskId, userId)
|
||||||
|
|
||||||
|
// Close popover by simulating click outside after assignment
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
}, 100)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to assign task:', error)
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get loading state from store
|
// Get loading state from store
|
||||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||||
|
|||||||
@@ -19,40 +19,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 space-y-1">
|
<div class="flex-1 min-w-0 space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Message Card -->
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border px-3 py-2"
|
||||||
|
:class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="font-semibold text-sm">
|
<span class="font-semibold text-sm">
|
||||||
{{ note.user_first_name }} {{ note.user_last_name }}
|
{{ note.user_first_name }} {{ note.user_last_name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-muted-foreground">
|
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
{{ formatDateTime(note.created_at) }}
|
{{ formatDateTime(note.created_at) }}
|
||||||
|
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
||||||
|
<Tooltip v-if="dateFormat === 'absolute'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(note.created_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
|
|
||||||
(edited)
|
|
||||||
</span>
|
|
||||||
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note Content -->
|
<!-- Note Content -->
|
||||||
<div v-if="!editing" class="text-sm whitespace-pre-wrap">
|
<div v-if="!editing" class="text-sm whitespace-pre-wrap mt-0.5">
|
||||||
{{ note.content }}
|
{{ note.content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2 mt-1">
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="editContent"
|
v-model="editContent"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="resize-none"
|
class="resize-none bg-background"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button size="sm" @click="handleSave">Save</Button>
|
<Button size="sm" @click="handleSave">Save</Button>
|
||||||
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div v-if="!editing" class="flex gap-2">
|
<div v-if="!editing" class="flex gap-2 px-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -64,30 +74,32 @@
|
|||||||
<Button
|
<Button
|
||||||
v-if="canEdit"
|
v-if="canEdit"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
|
title="Edit"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
>
|
||||||
<Pencil class="h-3 w-3 mr-1" />
|
<Pencil class="h-3.5 w-3.5" />
|
||||||
Edit
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
|
title="Delete"
|
||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3 w-3 mr-1" />
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
Delete
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Child Notes (Threaded) -->
|
<!-- Child Notes (Threaded) -->
|
||||||
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-4 space-y-4 pl-4 border-l-2">
|
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-3 space-y-3 pl-4 border-l-2">
|
||||||
<NoteItem
|
<NoteItem
|
||||||
v-for="childNote in note.child_notes"
|
v-for="childNote in note.child_notes"
|
||||||
:key="childNote.id"
|
:key="childNote.id"
|
||||||
:note="childNote"
|
:note="childNote"
|
||||||
:task-id="taskId"
|
:task-id="taskId"
|
||||||
|
:date-format="dateFormat"
|
||||||
|
:hide-client-badge="hideClientBadge"
|
||||||
@note-updated="emit('noteUpdated')"
|
@note-updated="emit('noteUpdated')"
|
||||||
@reply="emit('reply', $event)"
|
@reply="emit('reply', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -116,11 +128,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
import { Reply, Pencil, Trash2, Info } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -139,6 +152,8 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
note: ProductionNote
|
note: ProductionNote
|
||||||
taskId: number
|
taskId: number
|
||||||
|
dateFormat?: 'relative' | 'absolute'
|
||||||
|
hideClientBadge?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -170,8 +185,20 @@ function getInitials(firstName: string, lastName: string): string {
|
|||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(dateString: string): string {
|
function formatDateTime(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
|
|
||||||
|
if (props.dateFormat === 'absolute') {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = now.getTime() - date.getTime()
|
const diffMs = now.getTime() - date.getTime()
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
|||||||
@@ -12,31 +12,39 @@
|
|||||||
<!-- Task Details -->
|
<!-- Task Details -->
|
||||||
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
||||||
<template #badges>
|
|
||||||
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
|
|
||||||
</template>
|
|
||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
||||||
<!-- Tabs List (Fixed) -->
|
<!-- Tabs List (Fixed) -->
|
||||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">
|
<Info class="h-4 w-4" />
|
||||||
Notes
|
<span class="sr-only">Infos</span>
|
||||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
|
||||||
{{ notes.length }}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="attachments">
|
<TabsTrigger value="notes" title="Notes">
|
||||||
Attachments
|
<span class="relative inline-flex">
|
||||||
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
v-if="notes.length > 0"
|
||||||
|
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">Notes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="attachments" title="Attachments">
|
||||||
|
<Paperclip class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Attachments</span>
|
||||||
|
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ attachments.length }}
|
{{ attachments.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="submissions">
|
<TabsTrigger value="submissions" title="Submissions">
|
||||||
Submissions
|
<Upload class="h-4 w-4" />
|
||||||
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
|
<span class="sr-only">Submissions</span>
|
||||||
|
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ submissions.length }}
|
{{ submissions.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -111,16 +119,54 @@
|
|||||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<Label class="text-muted-foreground">Type</Label>
|
<Label class="text-muted-foreground">Type</Label>
|
||||||
<p class="text-sm mt-1">
|
<div class="mt-1">
|
||||||
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
|
<Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
|
||||||
</p>
|
<SelectTrigger class="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="taskType in taskTypeOptions" :key="taskType" :value="taskType">
|
||||||
|
{{ formatTaskType(taskType) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-muted-foreground">Department</Label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<Select :model-value="localDepartment || 'none'" @update:model-value="(value) => handleDepartmentChange(value === 'none' ? '' : (value as string))">
|
||||||
|
<SelectTrigger class="h-8">
|
||||||
|
<SelectValue placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem v-for="department in departmentOptions" :key="department" :value="department">
|
||||||
|
{{ formatDepartment(department) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
<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>
|
||||||
@@ -178,7 +224,12 @@
|
|||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" />
|
<TaskNotes
|
||||||
|
:task-id="taskId"
|
||||||
|
:notes="notes"
|
||||||
|
:initial-reply-note-id="initialReplyNoteId"
|
||||||
|
@notes-updated="loadNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Attachments Tab -->
|
<!-- Attachments Tab -->
|
||||||
@@ -188,7 +239,16 @@
|
|||||||
|
|
||||||
<!-- Submissions Tab -->
|
<!-- Submissions Tab -->
|
||||||
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
|
||||||
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
|
<TaskSubmissions
|
||||||
|
:task-id="taskId"
|
||||||
|
:submissions="submissions"
|
||||||
|
:task-type="task?.task_type"
|
||||||
|
:project-id="task?.project_id"
|
||||||
|
:name="task?.shot_name || task?.asset_name"
|
||||||
|
:task-name="task?.name"
|
||||||
|
:project-name="task?.project_name"
|
||||||
|
@submissions-updated="loadSubmissions"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,10 +322,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 } 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'
|
||||||
@@ -299,18 +360,20 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
import TaskStatusBadge from './TaskStatusBadge.vue'
|
|
||||||
import TaskNotes from './TaskNotes.vue'
|
import TaskNotes from './TaskNotes.vue'
|
||||||
import TaskAttachments from './TaskAttachments.vue'
|
import TaskAttachments from './TaskAttachments.vue'
|
||||||
import TaskSubmissions from './TaskSubmissions.vue'
|
import TaskSubmissions from './TaskSubmissions.vue'
|
||||||
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
|
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
|
import { customTaskTypeService } from '@/services/customTaskType'
|
||||||
|
import { useDepartmentsStore } from '@/stores/departments'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
initialTab?: string
|
initialTab?: string
|
||||||
|
initialReplyNoteId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -321,11 +384,17 @@ const emit = defineEmits<{
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { isCoordinatorOrAdmin } = usePermission()
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
|
const departmentsStore = useDepartmentsStore()
|
||||||
|
|
||||||
const task = ref<Task | null>(null)
|
const task = ref<Task | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
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 localDepartment = ref('')
|
||||||
|
const flatShotTaskTypes = ref<string[]>([])
|
||||||
|
const flatAssetTaskTypes = ref<string[]>([])
|
||||||
const notes = ref<ProductionNote[]>([])
|
const notes = ref<ProductionNote[]>([])
|
||||||
const attachments = ref<TaskAttachment[]>([])
|
const attachments = ref<TaskAttachment[]>([])
|
||||||
const submissions = ref<Submission[]>([])
|
const submissions = ref<Submission[]>([])
|
||||||
@@ -353,12 +422,48 @@ const canSubmitWork = computed(() => {
|
|||||||
|
|
||||||
const canReassign = computed(() => isCoordinatorOrAdmin.value)
|
const canReassign = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
|
// Departments are type-scoped (shot vs asset); a standalone task (neither
|
||||||
|
// shot nor asset) falls back to the unfiltered list.
|
||||||
|
const departmentOptions = computed(() => {
|
||||||
|
if (!task.value) return []
|
||||||
|
if (task.value.shot_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'shot').map(d => d.name)
|
||||||
|
if (task.value.asset_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'asset').map(d => d.name)
|
||||||
|
return departmentsStore.getAllDepartmentOptions(task.value.project_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Task Type options come from the current department's owned task types when
|
||||||
|
// it has any; otherwise fall back to the existing flat asset/shot task type list.
|
||||||
|
const taskTypeOptions = computed(() => {
|
||||||
|
if (!task.value) return []
|
||||||
|
const departmentTaskTypes = localDepartment.value
|
||||||
|
? departmentsStore.getDepartmentTaskTypes(task.value.project_id, localDepartment.value)
|
||||||
|
: []
|
||||||
|
if (departmentTaskTypes.length > 0) return departmentTaskTypes
|
||||||
|
|
||||||
|
const flatTypes = task.value.shot_id ? flatShotTaskTypes.value : flatAssetTaskTypes.value
|
||||||
|
// Always include the task's current type, even if it isn't in either list
|
||||||
|
// (e.g. a legacy or since-removed value), so the Select never shows blank.
|
||||||
|
return flatTypes.includes(task.value.task_type) ? flatTypes : [task.value.task_type, ...flatTypes]
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDepartment(department: string): string {
|
||||||
|
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
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 || ''
|
||||||
|
localDepartment.value = task.value.department || ''
|
||||||
|
departmentsStore.fetchProjectDepartments(task.value.project_id)
|
||||||
|
customTaskTypeService.getAllTaskTypes(task.value.project_id).then(types => {
|
||||||
|
flatShotTaskTypes.value = types.shot_task_types
|
||||||
|
flatAssetTaskTypes.value = types.asset_task_types
|
||||||
|
}).catch(err => console.error('Failed to load task types:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading task:', err)
|
console.error('Error loading task:', err)
|
||||||
error.value = err.response?.data?.detail || 'Failed to load task'
|
error.value = err.response?.data?.detail || 'Failed to load task'
|
||||||
@@ -418,6 +523,98 @@ 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 handleDepartmentChange(value: string) {
|
||||||
|
if (!task.value) return
|
||||||
|
|
||||||
|
const previousDepartment = task.value.department
|
||||||
|
const previousTaskType = task.value.task_type
|
||||||
|
|
||||||
|
// If the current task type doesn't belong to the newly-picked department
|
||||||
|
// (and that department has its own task types), reset to its first one so
|
||||||
|
// department and task type stay consistent.
|
||||||
|
const newDepartmentTaskTypes = value ? departmentsStore.getDepartmentTaskTypes(task.value.project_id, value) : []
|
||||||
|
const needsTaskTypeReset = newDepartmentTaskTypes.length > 0 && !newDepartmentTaskTypes.includes(task.value.task_type)
|
||||||
|
|
||||||
|
const payload: Record<string, any> = { department: value || null }
|
||||||
|
if (needsTaskTypeReset) {
|
||||||
|
payload.task_type = newDepartmentTaskTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await taskService.updateTask(props.taskId, payload as any)
|
||||||
|
task.value.department = updated.department
|
||||||
|
task.value.task_type = updated.task_type
|
||||||
|
localDepartment.value = updated.department || ''
|
||||||
|
emit('taskUpdated')
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Task department updated successfully'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating department:', error)
|
||||||
|
localDepartment.value = previousDepartment || ''
|
||||||
|
task.value.task_type = previousTaskType
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.response?.data?.detail || 'Failed to update task department',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskTypeChange(value: string) {
|
||||||
|
if (!task.value || value === task.value.task_type) return
|
||||||
|
|
||||||
|
const previousTaskType = task.value.task_type
|
||||||
|
const previousDepartment = task.value.department
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await taskService.updateTask(props.taskId, { task_type: value } as any)
|
||||||
|
task.value.task_type = updated.task_type
|
||||||
|
task.value.department = updated.department
|
||||||
|
localDepartment.value = updated.department || ''
|
||||||
|
emit('taskUpdated')
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Task type updated successfully'
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating task type:', error)
|
||||||
|
task.value.task_type = previousTaskType
|
||||||
|
task.value.department = previousDepartment
|
||||||
|
localDepartment.value = previousDepartment || ''
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.response?.data?.detail || 'Failed to update task type',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleQuickAction(action: 'start' | 'submit') {
|
async function handleQuickAction(action: 'start' | 'submit') {
|
||||||
if (!task.value) return
|
if (!task.value) return
|
||||||
|
|
||||||
@@ -497,19 +694,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()
|
||||||
|
|||||||
@@ -20,12 +20,23 @@
|
|||||||
<!-- Note Input (Bottom) -->
|
<!-- Note Input (Bottom) -->
|
||||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
|
||||||
|
<span class="truncate">
|
||||||
|
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
|
||||||
|
<span class="text-muted-foreground">— {{ replyToNote.content }}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="composerRef">
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="newNoteContent"
|
v-model="newNoteContent"
|
||||||
placeholder="Add a note..."
|
placeholder="Add a note..."
|
||||||
rows="2"
|
rows="2"
|
||||||
class="resize-none text-sm"
|
class="resize-none text-sm"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -60,8 +71,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
import { MessageSquarePlus, X } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import NoteItem from './NoteItem.vue'
|
import NoteItem from './NoteItem.vue'
|
||||||
@@ -71,6 +82,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
notes: ProductionNote[]
|
notes: ProductionNote[]
|
||||||
|
initialReplyNoteId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -83,6 +95,23 @@ const newNoteContent = ref('')
|
|||||||
const newNoteType = ref<NoteType>('internal')
|
const newNoteType = ref<NoteType>('internal')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const replyToNoteId = ref<number | null>(null)
|
const replyToNoteId = ref<number | null>(null)
|
||||||
|
const composerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
|
||||||
|
for (const note of notes) {
|
||||||
|
if (note.id === id) return note
|
||||||
|
if (note.child_notes) {
|
||||||
|
const found = findNote(note.child_notes, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyToNote = computed(() => {
|
||||||
|
if (replyToNoteId.value === null) return undefined
|
||||||
|
return findNote(props.notes, replyToNoteId.value)
|
||||||
|
})
|
||||||
|
|
||||||
async function handleAddNote() {
|
async function handleAddNote() {
|
||||||
if (!newNoteContent.value.trim()) return
|
if (!newNoteContent.value.trim()) return
|
||||||
@@ -117,6 +146,18 @@ async function handleAddNote() {
|
|||||||
|
|
||||||
function handleReply(noteId: number) {
|
function handleReply(noteId: number) {
|
||||||
replyToNoteId.value = noteId
|
replyToNoteId.value = noteId
|
||||||
// Focus on textarea (you could add a ref for this)
|
nextTick(() => {
|
||||||
|
composerRef.value?.querySelector('textarea')?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyToNoteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.initialReplyNoteId) {
|
||||||
|
handleReply(props.initialReplyNoteId)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,6 +22,26 @@
|
|||||||
<!-- Submit Work Form (Bottom) -->
|
<!-- Submit Work Form (Bottom) -->
|
||||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div v-if="submissionConfig" class="text-xs text-muted-foreground space-y-0.5 px-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span v-if="submissionConfig.allowed_extensions.length > 0">
|
||||||
|
Allowed: {{ submissionConfig.allowed_extensions.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
|
||||||
|
{{ submissionConfig.naming_pattern_is_regex ? 'Naming (regex):' : 'Naming:' }}
|
||||||
|
<code>{{ submissionConfig.naming_pattern }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
|
||||||
|
Movie spec:
|
||||||
|
<span v-if="submissionConfig.movie_resolution">{{ submissionConfig.movie_resolution }}</span>
|
||||||
|
<span v-if="submissionConfig.movie_format">.{{ submissionConfig.movie_format }}</span>
|
||||||
|
<span v-if="submissionConfig.movie_codec">({{ submissionConfig.movie_codec }})</span>
|
||||||
|
<span v-if="submissionConfig.movie_frame_rate">{{ submissionConfig.movie_frame_rate }}fps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
@@ -110,7 +130,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { Upload } from 'lucide-vue-next'
|
import { Upload } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -124,12 +144,20 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import SubmissionCard from './SubmissionCard.vue'
|
import SubmissionCard from './SubmissionCard.vue'
|
||||||
import { taskService, type Submission } from '@/services/task'
|
import { taskService, type Submission } from '@/services/task'
|
||||||
|
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { apiClient } from '@/services/api'
|
import { apiClient } from '@/services/api'
|
||||||
|
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
|
||||||
|
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
submissions: Submission[]
|
submissions: Submission[]
|
||||||
|
taskType?: string
|
||||||
|
projectId?: number
|
||||||
|
name?: string
|
||||||
|
taskName?: string
|
||||||
|
projectName?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -144,12 +172,213 @@ const submissionNotes = ref('')
|
|||||||
const viewerOpen = ref(false)
|
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 projectCode = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadSubmissionConfig() {
|
||||||
|
submissionConfig.value = null
|
||||||
|
projectCode.value = null
|
||||||
|
if (!props.projectId || !props.taskType) return
|
||||||
|
try {
|
||||||
|
const config = await projectService.getProjectSubmissionConfig(props.projectId)
|
||||||
|
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) {
|
||||||
|
console.error('Failed to load submission configuration:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSubmissionConfig)
|
||||||
|
watch(() => [props.projectId, props.taskType], loadSubmissionConfig)
|
||||||
|
|
||||||
|
const MOVIE_EXTENSIONS = ['.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf']
|
||||||
|
|
||||||
|
let mediaInfoPromise: ReturnType<typeof mediaInfoFactory> | null = null
|
||||||
|
function getMediaInfo() {
|
||||||
|
if (!mediaInfoPromise) {
|
||||||
|
mediaInfoPromise = mediaInfoFactory({ locateFile: () => mediaInfoWasmUrl })
|
||||||
|
}
|
||||||
|
return mediaInfoPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoTrackInfo {
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
format: string | null
|
||||||
|
codecId: string | null
|
||||||
|
formatCommercial: string | null
|
||||||
|
frameRate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses container/codec metadata directly from the file's bytes (no playback) - works for
|
||||||
|
// containers browsers can't play natively, e.g. MXF, or codecs like ProRes/DNxHD
|
||||||
|
async function analyzeVideoFile(file: File): Promise<VideoTrackInfo | null> {
|
||||||
|
try {
|
||||||
|
const mediainfo = await getMediaInfo()
|
||||||
|
const getSize = () => file.size
|
||||||
|
const readChunk = (chunkSize: number, offset: number) =>
|
||||||
|
new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
|
||||||
|
reader.onerror = () => reject(reader.error)
|
||||||
|
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
|
||||||
|
})
|
||||||
|
const result = await mediainfo.analyzeData(getSize, readChunk)
|
||||||
|
const videoTrack = result.media?.track.find(t => isTrackType(t, 'Video'))
|
||||||
|
if (!videoTrack) return null
|
||||||
|
const track = videoTrack as unknown as Record<string, unknown>
|
||||||
|
const commercial = [track.Format_Commercial, track.Format_Commercial_IfAny]
|
||||||
|
.filter((v): v is string => typeof v === 'string')
|
||||||
|
.join(' ')
|
||||||
|
return {
|
||||||
|
width: videoTrack.Width ?? null,
|
||||||
|
height: videoTrack.Height ?? null,
|
||||||
|
format: videoTrack.Format ?? null,
|
||||||
|
codecId: videoTrack.CodecID ?? null,
|
||||||
|
formatCommercial: commercial || null,
|
||||||
|
frameRate: videoTrack.FrameRate ?? null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to analyze video file:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEC_PATTERNS: Record<string, RegExp> = {
|
||||||
|
h264: /\b(avc|h\.?264)\b/i,
|
||||||
|
h265: /\b(hevc|h\.?265)\b/i,
|
||||||
|
mjpeg: /\bm?jpeg\b/i,
|
||||||
|
dnxhd: /\bdnxhd\b/i,
|
||||||
|
dnxhr: /\bdnxhr\b/i,
|
||||||
|
prores: /\bprores\b/i,
|
||||||
|
uncompressed: /\b(uncompressed|raw)\b/i,
|
||||||
|
avid: /\bavid\b/i,
|
||||||
|
cineform: /\bcineform\b/i
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodec(info: VideoTrackInfo): string | null {
|
||||||
|
const haystack = [info.format, info.formatCommercial, info.codecId].filter(Boolean).join(' ')
|
||||||
|
for (const [key, pattern] of Object.entries(CODEC_PATTERNS)) {
|
||||||
|
if (pattern.test(haystack)) return key
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checking happens client-side, entirely in the browser, before the file is ever uploaded
|
||||||
|
async function findSubmissionViolation(file: File): Promise<string | null> {
|
||||||
|
const config = submissionConfig.value
|
||||||
|
if (!config) return null
|
||||||
|
|
||||||
|
const filename = file.name
|
||||||
|
const dotIndex = filename.lastIndexOf('.')
|
||||||
|
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
|
||||||
|
const extension = dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ''
|
||||||
|
|
||||||
|
if (config.allowed_extensions.length > 0 && !config.allowed_extensions.includes(extension)) {
|
||||||
|
return `File type '${extension}' is not accepted for ${props.taskType} submissions. Allowed types: ${config.allowed_extensions.join(', ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.check_naming && config.naming_pattern) {
|
||||||
|
if (config.naming_pattern_is_regex) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(config.naming_pattern)
|
||||||
|
if (!regex.test(stem)) {
|
||||||
|
return `Filename does not match the required pattern '${config.naming_pattern}'`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid naming pattern regex:', error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tokenValues: Record<string, string | undefined> = {
|
||||||
|
name: props.name,
|
||||||
|
task_type: props.taskType,
|
||||||
|
task_name: props.taskName,
|
||||||
|
project_name: props.projectName,
|
||||||
|
project_code: projectCode.value || undefined
|
||||||
|
}
|
||||||
|
const usedTokens = config.naming_pattern.match(/\{(name|task_name|project_name|project_code)\}/g) || []
|
||||||
|
const unresolvable = usedTokens.some(token => !tokenValues[token.slice(1, -1)])
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
|
||||||
|
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
|
||||||
|
return `Movie format '${extension}' does not match the required format '.${config.movie_format}'`
|
||||||
|
}
|
||||||
|
if (config.movie_resolution || config.movie_codec || config.movie_frame_rate) {
|
||||||
|
const info = await analyzeVideoFile(file)
|
||||||
|
if (info) {
|
||||||
|
if (config.movie_resolution && info.width && info.height) {
|
||||||
|
const actual = `${info.width}x${info.height}`
|
||||||
|
if (actual !== config.movie_resolution) {
|
||||||
|
return `Video resolution ${actual} does not match the required resolution ${config.movie_resolution}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.movie_codec) {
|
||||||
|
const actualCodec = normalizeCodec(info)
|
||||||
|
if (actualCodec && actualCodec !== config.movie_codec) {
|
||||||
|
return `Video codec '${actualCodec}' does not match the required codec '${config.movie_codec}'`
|
||||||
|
}
|
||||||
|
// If the codec couldn't be identified, don't false-block
|
||||||
|
}
|
||||||
|
if (config.movie_frame_rate && info.frameRate) {
|
||||||
|
if (Math.abs(info.frameRate - config.movie_frame_rate) > 0.05) {
|
||||||
|
return `Video frame rate ${info.frameRate}fps does not match the required frame rate ${config.movie_frame_rate}fps`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the file couldn't be analyzed at all, don't false-block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileSelect(event: Event) {
|
async function handleFileSelect(event: Event) {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
|
const violation = await findSubmissionViolation(file)
|
||||||
|
if (violation) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid submission',
|
||||||
|
description: violation,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
await taskService.submitWork(
|
await taskService.submitWork(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<span class="truncate">
|
<span class="inline-flex items-center justify-center">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea
|
<textarea
|
||||||
|
v-model="modelValue"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
props.class
|
props.class
|
||||||
)"
|
)"
|
||||||
v-bind="$attrs"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string
|
class?: string
|
||||||
|
modelValue?: string
|
||||||
|
defaultValue?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
class: ''
|
class: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
|
|||||||
label: string
|
label: string
|
||||||
href?: string
|
href?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
/** True for the crumb representing the current project tab (Overview/Shots/Assets/...), so the header can render a tab-switcher dropdown on it. */
|
||||||
|
isTabCrumb?: boolean
|
||||||
|
/** True for the crumb representing the current project name, so the header can render a project-switcher dropdown on it. */
|
||||||
|
isProjectCrumb?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BreadcrumbService {
|
export class BreadcrumbService {
|
||||||
@@ -33,7 +37,8 @@ export class BreadcrumbService {
|
|||||||
// Add project breadcrumb
|
// Add project breadcrumb
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: project ? project.name : `Project ${projectId}`,
|
label: project ? project.name : `Project ${projectId}`,
|
||||||
href: `/projects/${projectId}`
|
href: `/projects/${projectId}`,
|
||||||
|
isProjectCrumb: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle tab-based navigation
|
// Handle tab-based navigation
|
||||||
@@ -45,7 +50,8 @@ export class BreadcrumbService {
|
|||||||
if (tab === 'shots' && route.params.episodeId) {
|
if (tab === 'shots' && route.params.episodeId) {
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: tabLabel,
|
label: tabLabel,
|
||||||
href: `/projects/${projectId}/shots`
|
href: `/projects/${projectId}/shots`,
|
||||||
|
isTabCrumb: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add episode context
|
// Add episode context
|
||||||
@@ -66,11 +72,12 @@ export class BreadcrumbService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (pathSegments[2] || tab !== 'overview') {
|
} else {
|
||||||
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to)
|
// Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: tabLabel,
|
label: tabLabel,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
isTabCrumb: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +92,8 @@ export class BreadcrumbService {
|
|||||||
if (crumbs.length > 1) {
|
if (crumbs.length > 1) {
|
||||||
crumbs[crumbs.length - 1] = {
|
crumbs[crumbs.length - 1] = {
|
||||||
label: 'Shots',
|
label: 'Shots',
|
||||||
href: `/projects/${projectId}/shots`
|
href: `/projects/${projectId}/shots`,
|
||||||
|
isTabCrumb: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { apiClient } from './api'
|
||||||
|
|
||||||
|
export type DepartmentType = 'shot' | 'asset'
|
||||||
|
|
||||||
|
export interface DepartmentInfo {
|
||||||
|
name: string
|
||||||
|
type: DepartmentType
|
||||||
|
task_types: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllDepartmentsResponse {
|
||||||
|
departments: DepartmentInfo[]
|
||||||
|
standard_departments: DepartmentInfo[]
|
||||||
|
custom_departments: DepartmentInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDepartmentCreate {
|
||||||
|
department: string
|
||||||
|
department_type: DepartmentType
|
||||||
|
task_types?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDepartmentUpdate {
|
||||||
|
old_name: string
|
||||||
|
new_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepartmentInUseError {
|
||||||
|
error: string
|
||||||
|
department: string
|
||||||
|
member_count: number
|
||||||
|
task_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepartmentTaskTypeInUseError {
|
||||||
|
error: string
|
||||||
|
department: string
|
||||||
|
task_type: string
|
||||||
|
task_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const departmentService = {
|
||||||
|
async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> {
|
||||||
|
const response = await apiClient.get(`/projects/${projectId}/departments`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDepartment(projectId: number, data: CustomDepartmentCreate): Promise<AllDepartmentsResponse> {
|
||||||
|
const response = await apiClient.post(`/projects/${projectId}/departments`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDepartment(projectId: number, department: string, data: CustomDepartmentUpdate): Promise<AllDepartmentsResponse> {
|
||||||
|
const encodedDepartment = encodeURIComponent(department)
|
||||||
|
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDepartment(projectId: number, department: string): Promise<AllDepartmentsResponse> {
|
||||||
|
const encodedDepartment = encodeURIComponent(department)
|
||||||
|
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
|
||||||
|
const encodedDepartment = encodeURIComponent(department)
|
||||||
|
const response = await apiClient.post(`/projects/${projectId}/departments/${encodedDepartment}/task-types`, { task_type: taskType })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async renameDepartmentTaskType(projectId: number, department: string, oldTaskType: string, newTaskType: string): Promise<AllDepartmentsResponse> {
|
||||||
|
const encodedDepartment = encodeURIComponent(department)
|
||||||
|
const encodedTaskType = encodeURIComponent(oldTaskType)
|
||||||
|
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`, {
|
||||||
|
old_name: oldTaskType,
|
||||||
|
new_name: newTaskType
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
|
||||||
|
const encodedDepartment = encodeURIComponent(department)
|
||||||
|
const encodedTaskType = encodeURIComponent(taskType)
|
||||||
|
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export interface ProjectMember {
|
|||||||
id: number
|
id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
project_id: number
|
project_id: number
|
||||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
department_role?: string
|
||||||
joined_at: string
|
joined_at: string
|
||||||
user_email: string
|
user_email: string
|
||||||
user_first_name: string
|
user_first_name: string
|
||||||
@@ -58,11 +58,11 @@ export interface ProjectUpdate {
|
|||||||
|
|
||||||
export interface ProjectMemberCreate {
|
export interface ProjectMemberCreate {
|
||||||
user_id: number
|
user_id: number
|
||||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
department_role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectMemberUpdate {
|
export interface ProjectMemberUpdate {
|
||||||
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing'
|
department_role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryMovieSpec {
|
export interface DeliveryMovieSpec {
|
||||||
@@ -95,6 +95,23 @@ export interface ProjectSettings {
|
|||||||
enabled_shot_tasks?: string[]
|
enabled_shot_tasks?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmissionTypeConfig {
|
||||||
|
allowed_extensions: string[]
|
||||||
|
naming_pattern_is_regex: boolean
|
||||||
|
naming_pattern?: string | null
|
||||||
|
check_naming: boolean
|
||||||
|
required: boolean
|
||||||
|
check_movie_spec: boolean
|
||||||
|
movie_resolution?: string | null
|
||||||
|
movie_format?: string | null
|
||||||
|
movie_codec?: string | null
|
||||||
|
movie_frame_rate?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSubmissionConfig {
|
||||||
|
config_by_task_type: Record<string, SubmissionTypeConfig>
|
||||||
|
}
|
||||||
|
|
||||||
export const projectService = {
|
export const projectService = {
|
||||||
async getUserProjects(): Promise<Project[]> {
|
async getUserProjects(): Promise<Project[]> {
|
||||||
const response = await apiClient.get('/projects/')
|
const response = await apiClient.get('/projects/')
|
||||||
@@ -182,6 +199,16 @@ export const projectService = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getProjectSubmissionConfig(projectId: number): Promise<ProjectSubmissionConfig> {
|
||||||
|
const response = await apiClient.get(`/projects/${projectId}/submission-config`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProjectSubmissionConfig(projectId: number, config: ProjectSubmissionConfig): Promise<ProjectSubmissionConfig> {
|
||||||
|
const response = await apiClient.put(`/projects/${projectId}/submission-config`, config)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
|
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface Task {
|
|||||||
description?: string
|
description?: string
|
||||||
task_type: string
|
task_type: string
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
|
department?: string
|
||||||
|
start_date?: string
|
||||||
deadline?: string
|
deadline?: string
|
||||||
project_id: number
|
project_id: number
|
||||||
project_name?: string
|
project_name?: string
|
||||||
@@ -33,6 +35,8 @@ export interface TaskListItem {
|
|||||||
name: string
|
name: string
|
||||||
task_type: string
|
task_type: string
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
|
department?: string
|
||||||
|
start_date?: string
|
||||||
deadline?: string
|
deadline?: string
|
||||||
project_id: number
|
project_id: number
|
||||||
project_name: string
|
project_name: string
|
||||||
@@ -107,6 +111,11 @@ export interface Submission {
|
|||||||
stream_url?: string
|
stream_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmissionDateInfo {
|
||||||
|
task_id: number
|
||||||
|
submitted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskFilters {
|
export interface TaskFilters {
|
||||||
projectId?: number
|
projectId?: number
|
||||||
shotId?: number
|
shotId?: number
|
||||||
@@ -115,6 +124,7 @@ export interface TaskFilters {
|
|||||||
status?: string
|
status?: string
|
||||||
taskType?: string
|
taskType?: string
|
||||||
departmentRole?: string
|
departmentRole?: string
|
||||||
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkStatusUpdateRequest {
|
export interface BulkStatusUpdateRequest {
|
||||||
@@ -143,6 +153,7 @@ class TaskService {
|
|||||||
if (filters?.status) params.append('status', filters.status)
|
if (filters?.status) params.append('status', filters.status)
|
||||||
if (filters?.taskType) params.append('task_type', filters.taskType)
|
if (filters?.taskType) params.append('task_type', filters.taskType)
|
||||||
if (filters?.departmentRole) params.append('department_role', filters.departmentRole)
|
if (filters?.departmentRole) params.append('department_role', filters.departmentRole)
|
||||||
|
if (filters?.limit) params.append('limit', filters.limit.toString())
|
||||||
|
|
||||||
const response = await apiClient.get(`/tasks/?${params}`)
|
const response = await apiClient.get(`/tasks/?${params}`)
|
||||||
return response.data
|
return response.data
|
||||||
@@ -245,6 +256,11 @@ class TaskService {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSubmissionDates(projectId: number): Promise<SubmissionDateInfo[]> {
|
||||||
|
const response = await apiClient.get(`/tasks/submission-dates?project_id=${projectId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
|
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { departmentService, type AllDepartmentsResponse, type DepartmentInfo, type DepartmentType } from '@/services/department'
|
||||||
|
|
||||||
|
interface ProjectDepartments {
|
||||||
|
projectId: number
|
||||||
|
data: AllDepartmentsResponse
|
||||||
|
lastFetched: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDepartmentsStore = defineStore('departments', () => {
|
||||||
|
// Cache departments by project ID
|
||||||
|
const projectDepartments = ref<Map<number, ProjectDepartments>>(new Map())
|
||||||
|
const loading = ref<Set<number>>(new Set())
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// In-flight request de-dup: concurrent callers for the same project share one promise
|
||||||
|
const inFlightRequests = new Map<number, Promise<AllDepartmentsResponse>>()
|
||||||
|
|
||||||
|
// Cache duration: 5 minutes
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000
|
||||||
|
|
||||||
|
// Get cached departments for a project
|
||||||
|
const getProjectDepartments = computed(() => {
|
||||||
|
return (projectId: number): AllDepartmentsResponse | null => {
|
||||||
|
const cached = projectDepartments.value.get(projectId)
|
||||||
|
if (!cached) return null
|
||||||
|
|
||||||
|
// Check if cache is still valid
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - cached.lastFetched > CACHE_DURATION) {
|
||||||
|
// Cache expired, remove it
|
||||||
|
projectDepartments.value.delete(projectId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if departments are currently being loaded for a project
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
return (projectId: number): boolean => {
|
||||||
|
return loading.value.has(projectId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all department names (standard + custom) for a project
|
||||||
|
const getAllDepartmentOptions = computed(() => {
|
||||||
|
return (projectId: number): string[] => {
|
||||||
|
const departments = getProjectDepartments.value(projectId)
|
||||||
|
if (!departments) return []
|
||||||
|
return departments.departments.map(d => d.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all departments of a given type (shot or asset) for a project
|
||||||
|
const getDepartmentsByType = computed(() => {
|
||||||
|
return (projectId: number, type: DepartmentType): DepartmentInfo[] => {
|
||||||
|
const departments = getProjectDepartments.value(projectId)
|
||||||
|
if (!departments) return []
|
||||||
|
return departments.departments.filter(d => d.type === type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the task types owned by a specific department for a project
|
||||||
|
const getDepartmentTaskTypes = computed(() => {
|
||||||
|
return (projectId: number, departmentName: string): string[] => {
|
||||||
|
const departments = getProjectDepartments.value(projectId)
|
||||||
|
if (!departments) return []
|
||||||
|
const department = departments.departments.find(d => d.name === departmentName)
|
||||||
|
return department?.task_types || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch departments for a project
|
||||||
|
async function fetchProjectDepartments(projectId: number, force = false): Promise<AllDepartmentsResponse> {
|
||||||
|
// Return cached data if available and not forced
|
||||||
|
if (!force) {
|
||||||
|
const cached = getProjectDepartments.value(projectId)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share the in-flight request with any concurrent callers instead of re-fetching
|
||||||
|
const existing = inFlightRequests.get(projectId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value.add(projectId)
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await departmentService.getAllDepartments(projectId)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
projectDepartments.value.set(projectId, {
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
lastFetched: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to fetch departments'
|
||||||
|
console.error('Error fetching departments:', err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value.delete(projectId)
|
||||||
|
inFlightRequests.delete(projectId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inFlightRequests.set(projectId, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for a project (useful after creating/updating/deleting departments)
|
||||||
|
function invalidateProject(projectId: number) {
|
||||||
|
projectDepartments.value.delete(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all cached data
|
||||||
|
function clearCache() {
|
||||||
|
projectDepartments.value.clear()
|
||||||
|
loading.value.clear()
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cached departments after a change (to avoid refetch)
|
||||||
|
function updateProjectDepartments(projectId: number, data: AllDepartmentsResponse) {
|
||||||
|
projectDepartments.value.set(projectId, {
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
lastFetched: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
getProjectDepartments,
|
||||||
|
isLoading,
|
||||||
|
getAllDepartmentOptions,
|
||||||
|
getDepartmentsByType,
|
||||||
|
getDepartmentTaskTypes,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchProjectDepartments,
|
||||||
|
invalidateProject,
|
||||||
|
clearCache,
|
||||||
|
updateProjectDepartments
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -38,15 +38,6 @@
|
|||||||
<!-- </div> -->
|
<!-- </div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project Tabs -->
|
|
||||||
<div class="px-0 sm:px-0 pb-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" v-if="project">
|
|
||||||
<ProjectTabs
|
|
||||||
:project-id="project.id"
|
|
||||||
:shot-count="project.shot_count"
|
|
||||||
:asset-count="project.asset_count"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Content Area -->
|
<!-- Tab Content Area -->
|
||||||
<div class="flex-1 overflow-auto bg-muted/30">
|
<div class="flex-1 overflow-auto bg-muted/30">
|
||||||
<router-view :key="route.fullPath" />
|
<router-view :key="route.fullPath" />
|
||||||
@@ -87,7 +78,6 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import ProjectTabs from '@/components/project/ProjectTabs.vue'
|
|
||||||
import type { Project } from '@/stores/projects'
|
import type { Project } from '@/stores/projects'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<!-- Tabbed Interface -->
|
<!-- Tabbed Interface -->
|
||||||
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
|
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-6">
|
<TabsList class="grid w-full grid-cols-8">
|
||||||
<TabsTrigger value="general">
|
<TabsTrigger value="general">
|
||||||
<Settings class="h-4 w-4 mr-2" />
|
<Settings class="h-4 w-4 mr-2" />
|
||||||
General
|
General
|
||||||
@@ -46,6 +46,10 @@
|
|||||||
<Users class="h-4 w-4 mr-2" />
|
<Users class="h-4 w-4 mr-2" />
|
||||||
Team
|
Team
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="departments">
|
||||||
|
<Building2 class="h-4 w-4 mr-2" />
|
||||||
|
Departments
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="technical">
|
<TabsTrigger value="technical">
|
||||||
<Cog class="h-4 w-4 mr-2" />
|
<Cog class="h-4 w-4 mr-2" />
|
||||||
Technical
|
Technical
|
||||||
@@ -54,6 +58,10 @@
|
|||||||
<ListChecks class="h-4 w-4 mr-2" />
|
<ListChecks class="h-4 w-4 mr-2" />
|
||||||
Tasks
|
Tasks
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="submissions">
|
||||||
|
<UploadCloud class="h-4 w-4 mr-2" />
|
||||||
|
Submissions
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="storage">
|
<TabsTrigger value="storage">
|
||||||
<FolderOpen class="h-4 w-4 mr-2" />
|
<FolderOpen class="h-4 w-4 mr-2" />
|
||||||
Storage
|
Storage
|
||||||
@@ -105,6 +113,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Department Management Tab -->
|
||||||
|
<TabsContent value="departments" class="mt-6">
|
||||||
|
<div class="bg-card rounded-lg border p-6">
|
||||||
|
<DepartmentManager
|
||||||
|
:project-id="projectId"
|
||||||
|
@updated="handleDepartmentsUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Technical Specifications Tab -->
|
<!-- Technical Specifications Tab -->
|
||||||
<TabsContent value="technical" class="mt-6">
|
<TabsContent value="technical" class="mt-6">
|
||||||
<div class="bg-card rounded-lg border p-6">
|
<div class="bg-card rounded-lg border p-6">
|
||||||
@@ -156,6 +174,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Submission Configuration Tab -->
|
||||||
|
<TabsContent value="submissions" class="mt-6">
|
||||||
|
<div class="bg-card rounded-lg border p-6">
|
||||||
|
<SubmissionConfigManager :project-id="projectId" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Upload Location Tab -->
|
<!-- Upload Location Tab -->
|
||||||
<TabsContent value="storage" class="mt-6">
|
<TabsContent value="storage" class="mt-6">
|
||||||
<div class="bg-card rounded-lg border p-6">
|
<div class="bg-card rounded-lg border p-6">
|
||||||
@@ -177,7 +202,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
|
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
|
||||||
ListChecks, FolderOpen
|
ListChecks, FolderOpen, UploadCloud, Building2
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -193,8 +218,10 @@ import ProjectThumbnailUpload from "@/components/project/ProjectThumbnailUpload.
|
|||||||
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
|
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
|
||||||
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
|
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
|
||||||
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
|
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
|
||||||
|
import DepartmentManager from "@/components/settings/DepartmentManager.vue";
|
||||||
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
|
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
|
||||||
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
|
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
|
||||||
|
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
|
||||||
import { projectService } from "@/services/project";
|
import { projectService } from "@/services/project";
|
||||||
import type { Project } from "@/stores/projects";
|
import type { Project } from "@/stores/projects";
|
||||||
|
|
||||||
@@ -378,6 +405,13 @@ const handleTaskStatusesUpdated = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDepartmentsUpdated = () => {
|
||||||
|
toast({
|
||||||
|
title: 'Departments updated',
|
||||||
|
description: 'Department changes have been saved successfully.'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleTaskTypesUpdated = async () => {
|
const handleTaskTypesUpdated = async () => {
|
||||||
// Refresh task types in the task templates editor
|
// Refresh task types in the task templates editor
|
||||||
if (taskTemplatesEditorRef.value) {
|
if (taskTemplatesEditorRef.value) {
|
||||||
|
|||||||
@@ -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