Compare commits
37 Commits
0dd37d1706
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d67c304a3 | |||
| e1dd5c6eae | |||
| 31d17780a4 | |||
| 9bbd3df53c | |||
| c09710f4e5 | |||
| 11e1369f2f | |||
| 7f260067a2 | |||
| 04c85be0f7 | |||
| 74be250912 | |||
| 172e05af3e | |||
| 9c5abf6342 | |||
| f547d05478 | |||
| 981808b901 | |||
| 23740af816 | |||
| 762bd34f74 | |||
| 4bffec642e | |||
| 548d1081ba | |||
| 960753b3d6 | |||
| db2c414c1a | |||
| 976ec40b52 | |||
| eb5587eb40 | |||
| f013e1cf25 | |||
| e628c99498 | |||
| 442abc3586 | |||
| 9527f06b3f | |||
| 4f9deeb57a | |||
| 841e786fdd | |||
| 26807984ee | |||
| cd3628a255 | |||
| e8b26487db | |||
| cd2efe3587 | |||
| c63a71883b | |||
| 61ee1a7364 | |||
| 14320693c7 | |||
| d44398033a | |||
| b3ee14cc6e | |||
| 0acf9ddff2 |
@@ -33,9 +33,11 @@ LinkDesk is a VFX/animation production management system: FastAPI backend + Vue
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
# venv lives at repo root, not inside backend/
|
||||
cd backend
|
||||
# Windows: .venv\Scripts\activate | bash/mac: source .venv/bin/activate
|
||||
..\venv\Scripts\activate # Windows PowerShell
|
||||
uvicorn main:app --reload --port 8000
|
||||
# or invoke directly: D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -34,9 +34,11 @@ LinkDesk is a VFX/animation production management system. It has a FastAPI backe
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
# venv is at repo root, not inside backend/
|
||||
cd backend
|
||||
# Activate venv first (Windows)
|
||||
.venv\Scripts\activate # or: source .venv/bin/activate on bash
|
||||
..\venv\Scripts\activate # Windows PowerShell
|
||||
# or run directly:
|
||||
# D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""One-time migration: add performance indexes for shot table queries."""
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
|
||||
indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_tasks_shot_id ON tasks (shot_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_tasks_assigned_user_id ON tasks (assigned_user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_tasks_deleted_at ON tasks (deleted_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_episodes_project_id ON episodes (project_id)",
|
||||
]
|
||||
|
||||
with engine.connect() as conn:
|
||||
for sql in indexes:
|
||||
conn.execute(text(sql))
|
||||
print(f"OK: {sql}")
|
||||
conn.commit()
|
||||
|
||||
print("Done.")
|
||||
@@ -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!")
|
||||
+2
-1
@@ -8,7 +8,7 @@ import json
|
||||
import os
|
||||
|
||||
from database import engine, Base
|
||||
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency
|
||||
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency, roles
|
||||
# Import models to ensure they are registered with SQLAlchemy
|
||||
import models
|
||||
|
||||
@@ -109,6 +109,7 @@ app.include_router(notifications.router, tags=["notifications"])
|
||||
app.include_router(activities.router, tags=["activities"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"])
|
||||
app.include_router(roles.router, prefix="/roles", tags=["roles"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -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,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-off migration script for the multi-role permission system.
|
||||
|
||||
Creates the new roles/permissions/role_permissions/user_roles tables, seeds
|
||||
the permission catalog (asset/shot/task create/edit/delete, plus assignment,
|
||||
review, submission, upload, and note actions), seeds the 4 system roles
|
||||
(coordinator/director/artist/developer) with role_permissions matching
|
||||
today's actual behavior, then backfills user_roles from each existing
|
||||
user's legacy `role` column.
|
||||
|
||||
Does not read or write users.role or users.is_admin beyond reading `role` to
|
||||
resolve which system Role to link. Safe to re-run (every step is idempotent
|
||||
and purely additive - re-running after adding new PERMISSIONS/grants below
|
||||
only inserts what's missing, never removes or resets existing data).
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import DATABASE_URL, Base
|
||||
from models.user import User
|
||||
from models.role import Role, Permission
|
||||
import logging
|
||||
|
||||
import models # noqa: F401 - ensures every model is registered on Base.metadata
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The full permission catalog: (resource, action, description).
|
||||
PERMISSIONS = [
|
||||
("asset", "create", "Can create assets"),
|
||||
("asset", "edit", "Can edit assets"),
|
||||
("asset", "delete", "Can delete assets"),
|
||||
("shot", "create", "Can create shots"),
|
||||
("shot", "edit", "Can edit shots"),
|
||||
("shot", "delete", "Can delete shots"),
|
||||
("task", "create", "Can create tasks"),
|
||||
("task", "edit", "Can edit tasks"),
|
||||
("task", "delete", "Can delete tasks"),
|
||||
("task", "change_status", "Can change task status"),
|
||||
("assignment", "create", "Can assign a task to a user"),
|
||||
("assignment", "edit", "Can reassign a task to a different user"),
|
||||
("assignment", "delete", "Can unassign a task"),
|
||||
("review", "publish", "Can approve a submission"),
|
||||
("review", "retake", "Can request a retake on a submission"),
|
||||
("submission", "create", "Can submit work for a task"),
|
||||
("submission", "edit_self", "Can edit your own submission notes"),
|
||||
("submission", "delete_self", "Can delete your own submission"),
|
||||
("submission", "edit_other", "Can edit another user's submission notes"),
|
||||
("submission", "delete_other", "Can delete another user's submission"),
|
||||
("upload", "create", "Can upload task attachments"),
|
||||
("upload", "delete", "Can delete task attachments"),
|
||||
("note", "create", "Can add task notes"),
|
||||
("note", "edit_self", "Can edit your own note"),
|
||||
("note", "delete_self", "Can delete your own note"),
|
||||
("note", "edit_other", "Can edit another user's note"),
|
||||
("note", "delete_other", "Can delete another user's note"),
|
||||
("note", "view_internal", "Can view internal notes"),
|
||||
("note", "view_client", "Can view client notes"),
|
||||
]
|
||||
|
||||
SYSTEM_ROLE_DESCRIPTIONS = {
|
||||
"coordinator": "Can create, edit, and delete assets, shots, and tasks.",
|
||||
"director": "Director role, migrated from the legacy single-role system.",
|
||||
"artist": "Artist role, migrated from the legacy single-role system.",
|
||||
"developer": "Developer role, migrated from the legacy single-role system.",
|
||||
}
|
||||
|
||||
# Explicit per-role grants, faithful to today's actual behavior for each
|
||||
# action (not a blanket "coordinator gets everything" - e.g. director
|
||||
# already has real elevated rights on review publish/retake today, and
|
||||
# non-artist roles already have unrestricted note/upload create today).
|
||||
# submission:create is granted to nobody by default since today it's a pure
|
||||
# ownership check (must literally be the assigned user), not role-based -
|
||||
# is_admin still bypasses it, and self-service is preserved in the endpoint.
|
||||
# note:view_internal goes to everyone (matches today - all notes are visible
|
||||
# to anyone with task access); note:view_client is new/more restrictive by
|
||||
# default, granted only to coordinator+director.
|
||||
# task:change_status is granted to coordinator only. This deliberately
|
||||
# resolves a pre-existing inconsistency: the single-task status endpoint only
|
||||
# allowed COORDINATOR (+artist self-service), while the bulk endpoint also
|
||||
# allowed DIRECTOR - now both use this one permission, so director loses the
|
||||
# bulk-only status-change access they had before (can be re-granted via a
|
||||
# custom role if that capability is actually wanted).
|
||||
# note/submission edit_self+delete_self are granted to every system role -
|
||||
# today, editing/deleting your OWN note or submission is an unconditional
|
||||
# right with no role check at all, so faithful migration means everyone
|
||||
# gets it by default. edit_other/delete_other (acting on someone ELSE's
|
||||
# note or submission) are the elevated capability and are granted to
|
||||
# nobody by default - not even coordinator.
|
||||
SELF_SERVICE_GRANTS = {
|
||||
("note", "edit_self"), ("note", "delete_self"),
|
||||
("submission", "edit_self"), ("submission", "delete_self"),
|
||||
}
|
||||
SYSTEM_ROLE_GRANTS = {
|
||||
"coordinator": SELF_SERVICE_GRANTS | {
|
||||
("asset", "create"), ("asset", "edit"), ("asset", "delete"),
|
||||
("shot", "create"), ("shot", "edit"), ("shot", "delete"),
|
||||
("task", "create"), ("task", "edit"), ("task", "delete"), ("task", "change_status"),
|
||||
("assignment", "create"), ("assignment", "edit"), ("assignment", "delete"),
|
||||
("review", "publish"), ("review", "retake"),
|
||||
("upload", "create"), ("upload", "delete"),
|
||||
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
|
||||
},
|
||||
"director": SELF_SERVICE_GRANTS | {
|
||||
("review", "publish"), ("review", "retake"),
|
||||
("upload", "create"),
|
||||
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
|
||||
},
|
||||
"artist": SELF_SERVICE_GRANTS | {
|
||||
("note", "view_internal"),
|
||||
},
|
||||
"developer": SELF_SERVICE_GRANTS | {
|
||||
("upload", "create"),
|
||||
("note", "create"), ("note", "view_internal"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def migrate_role_permissions():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 0. Add note_type to production_notes if missing (create_all only
|
||||
# creates missing tables, it doesn't alter existing ones).
|
||||
try:
|
||||
db.execute(text("SELECT note_type FROM production_notes LIMIT 1"))
|
||||
logger.info("note_type column already exists")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.info("Adding note_type column to production_notes table")
|
||||
db.execute(text(
|
||||
"ALTER TABLE production_notes ADD COLUMN note_type VARCHAR(8) NOT NULL DEFAULT 'internal'"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
# 0b. One-time rename: note/submission edit and delete were renamed
|
||||
# to edit_other/delete_other to make explicit that they only govern
|
||||
# acting on someone else's note or submission (your own is always
|
||||
# editable/deletable via ownership, no permission needed). Renaming
|
||||
# the existing rows in place (rather than leaving the old ones
|
||||
# orphaned and adding new ones) preserves their ids; idempotent -
|
||||
# a no-op once renamed. Not a general rename mechanism.
|
||||
RENAMES = [
|
||||
("note", "edit", "edit_other", "Can edit another user's note"),
|
||||
("note", "delete", "delete_other", "Can delete another user's note"),
|
||||
("submission", "edit", "edit_other", "Can edit another user's submission notes"),
|
||||
("submission", "delete", "delete_other", "Can delete another user's submission"),
|
||||
]
|
||||
renamed = 0
|
||||
for resource, old_action, new_action, new_description in RENAMES:
|
||||
perm = db.query(Permission).filter(
|
||||
Permission.resource == resource, Permission.action == old_action
|
||||
).first()
|
||||
if perm:
|
||||
perm.action = new_action
|
||||
perm.description = new_description
|
||||
renamed += 1
|
||||
db.commit()
|
||||
if renamed:
|
||||
logger.info(f"Permissions: {renamed} renamed to *_other")
|
||||
|
||||
# 1. Seed permissions (idempotent by resource+action)
|
||||
permissions_by_key = {}
|
||||
created_permissions = 0
|
||||
for resource, action, description in PERMISSIONS:
|
||||
perm = db.query(Permission).filter(
|
||||
Permission.resource == resource, Permission.action == action
|
||||
).first()
|
||||
if not perm:
|
||||
perm = Permission(resource=resource, action=action, description=description)
|
||||
db.add(perm)
|
||||
db.flush()
|
||||
created_permissions += 1
|
||||
permissions_by_key[(resource, action)] = perm
|
||||
logger.info(f"Permissions: {created_permissions} created, {len(permissions_by_key)} total")
|
||||
|
||||
# 2. Seed system roles (idempotent by name)
|
||||
roles_by_name = {}
|
||||
created_roles = 0
|
||||
for name, description in SYSTEM_ROLE_DESCRIPTIONS.items():
|
||||
role = db.query(Role).filter(Role.name == name).first()
|
||||
if not role:
|
||||
role = Role(name=name, description=description, is_system=True)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
created_roles += 1
|
||||
roles_by_name[name] = role
|
||||
logger.info(f"System roles: {created_roles} created, {len(roles_by_name)} total")
|
||||
|
||||
# 3. Link role_permissions (idempotent - only add missing links)
|
||||
linked = 0
|
||||
for name, grants in SYSTEM_ROLE_GRANTS.items():
|
||||
role = roles_by_name[name]
|
||||
for key in grants:
|
||||
perm = permissions_by_key[key]
|
||||
if perm not in role.permissions:
|
||||
role.permissions.append(perm)
|
||||
linked += 1
|
||||
db.commit()
|
||||
logger.info(f"role_permissions: {linked} new links created")
|
||||
|
||||
# 3b. One-time correction: an earlier version of this script granted
|
||||
# coordinator submission:edit/submission:delete by default, which was
|
||||
# wrong (those should be own-only by default, same as note:edit/
|
||||
# delete). Revoke them if still present, idempotent - a no-op once
|
||||
# corrected. Not a general revoke mechanism, just fixing this one
|
||||
# past mistake.
|
||||
coordinator = roles_by_name.get("coordinator")
|
||||
revoked = 0
|
||||
if coordinator:
|
||||
for key in [("submission", "edit"), ("submission", "delete")]:
|
||||
perm = permissions_by_key.get(key)
|
||||
if perm and perm in coordinator.permissions:
|
||||
coordinator.permissions.remove(perm)
|
||||
revoked += 1
|
||||
db.commit()
|
||||
if revoked:
|
||||
logger.info(f"role_permissions: {revoked} over-grant(s) revoked from coordinator (submission edit/delete correction)")
|
||||
|
||||
# 4. Backfill user_roles from users.role (idempotent - only add missing links)
|
||||
users = db.query(User).all()
|
||||
backfilled = 0
|
||||
for user in users:
|
||||
role = roles_by_name.get(user.role.value)
|
||||
if role and role not in user.roles:
|
||||
user.roles.append(role)
|
||||
backfilled += 1
|
||||
db.commit()
|
||||
logger.info(f"user_roles: {backfilled} new links created for {len(users)} users")
|
||||
|
||||
# Verification summary
|
||||
logger.info("--- Migration summary ---")
|
||||
for name, role in roles_by_name.items():
|
||||
logger.info(f" Role '{name}': is_system={role.is_system}, permissions={len(role.permissions)}, users={len(role.users)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting role/permission migration...")
|
||||
migrate_role_permissions()
|
||||
logger.info("Migration completed!")
|
||||
@@ -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,22 +1,23 @@
|
||||
# Models package
|
||||
from .user import User, UserRole, DepartmentRole
|
||||
from .user import User, UserRole
|
||||
from .project import Project, ProjectMember, ProjectStatus
|
||||
from .episode import Episode, EpisodeStatus
|
||||
from .asset import Asset, AssetCategory, AssetStatus
|
||||
from .shot import Shot, ShotStatus
|
||||
from .task import (
|
||||
Task, Submission, Review, ProductionNote, TaskAttachment,
|
||||
TaskType, TaskStatus, ReviewDecision, AttachmentType
|
||||
TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
||||
)
|
||||
from .api_key import APIKey, APIKeyScope
|
||||
from .api_key_usage import APIKeyUsage
|
||||
from .global_settings import GlobalSettings
|
||||
from .notification import Notification, UserNotificationPreference, NotificationType
|
||||
from .activity import Activity, ActivityType
|
||||
from .role import Role, Permission, role_permissions, user_roles
|
||||
|
||||
__all__ = [
|
||||
# User models
|
||||
"User", "UserRole", "DepartmentRole",
|
||||
"User", "UserRole",
|
||||
# Project models
|
||||
"Project", "ProjectMember", "ProjectStatus",
|
||||
# Episode models
|
||||
@@ -27,7 +28,7 @@ __all__ = [
|
||||
"Shot", "ShotStatus",
|
||||
# Task models
|
||||
"Task", "Submission", "Review", "ProductionNote", "TaskAttachment",
|
||||
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType",
|
||||
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "NoteType",
|
||||
# API Key models
|
||||
"APIKey", "APIKeyScope", "APIKeyUsage",
|
||||
# Global Settings models
|
||||
@@ -35,5 +36,7 @@ __all__ = [
|
||||
# Notification models
|
||||
"Notification", "UserNotificationPreference", "NotificationType",
|
||||
# Activity models
|
||||
"Activity", "ActivityType"
|
||||
"Activity", "ActivityType",
|
||||
# Role models
|
||||
"Role", "Permission", "role_permissions", "user_roles"
|
||||
]
|
||||
@@ -17,7 +17,7 @@ class Episode(Base):
|
||||
__tablename__ = "episodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
episode_number = Column(Integer, nullable=False)
|
||||
|
||||
@@ -2,7 +2,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
from .user import DepartmentRole
|
||||
import enum
|
||||
|
||||
|
||||
@@ -54,6 +53,12 @@ class Project(Base):
|
||||
# Custom task statuses
|
||||
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
||||
|
||||
# Custom departments
|
||||
custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
|
||||
|
||||
# Submission configuration per task type
|
||||
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
|
||||
|
||||
# Project thumbnail
|
||||
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)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
department_role = Column(Enum(DepartmentRole), nullable=True)
|
||||
department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Table, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
resource = Column(String, nullable=False, index=True) # e.g. "asset", "shot", "task"
|
||||
action = Column(String, nullable=False) # e.g. "create", "edit", "delete"
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("resource", "action", name="uq_permission_resource_action"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(id={self.id}, resource='{self.resource}', action='{self.action}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
is_system = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(id={self.id}, name='{self.name}', is_system={self.is_system})>"
|
||||
+11
-3
@@ -38,25 +38,32 @@ class AttachmentType(str, enum.Enum):
|
||||
DOCUMENTATION = "documentation"
|
||||
|
||||
|
||||
class NoteType(str, enum.Enum):
|
||||
INTERNAL = "internal"
|
||||
CLIENT = "client"
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
episode_id = Column(Integer, ForeignKey("episodes.id"), nullable=True)
|
||||
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True)
|
||||
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True, index=True)
|
||||
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True)
|
||||
assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
task_type = Column(String, nullable=False) # Changed from Enum to String to support custom task types
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
|
||||
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
|
||||
start_date = Column(Date)
|
||||
deadline = Column(Date)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
@@ -190,6 +197,7 @@ class ProductionNote(Base):
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
note_type = Column(Enum(NoteType), nullable=False, default=NoteType.INTERNAL)
|
||||
parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
+1
-10
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
|
||||
DEVELOPER = "developer"
|
||||
|
||||
|
||||
class DepartmentRole(str, enum.Enum):
|
||||
LAYOUT = "layout"
|
||||
ANIMATION = "animation"
|
||||
LIGHTING = "lighting"
|
||||
COMPOSITE = "composite"
|
||||
MODELING = "modeling"
|
||||
RIGGING = "rigging"
|
||||
SURFACING = "surfacing"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -47,6 +37,7 @@ class User(Base):
|
||||
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
||||
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
|
||||
notification_preferences = relationship("UserNotificationPreference", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
roles = relationship("Role", secondary="user_roles", back_populates="users")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
@@ -5,7 +5,7 @@ from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.user import User, UserRole
|
||||
from models.activity import Activity, ActivityType
|
||||
from models.project import ProjectMember
|
||||
from schemas.activity import ActivityResponse
|
||||
@@ -26,13 +26,15 @@ def get_project_activities(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity feed for a specific project (excludes activities for deleted records)."""
|
||||
# Verify user has access to the project
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins have access to all projects (matches shots.py/assets.py).
|
||||
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 and not current_user.is_admin:
|
||||
if not member:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
|
||||
@@ -66,13 +68,15 @@ def get_task_activities(
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check if user is a member of the project
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins have access to all tasks (matches shots.py/assets.py).
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
if not member:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
@@ -122,19 +126,27 @@ def get_recent_activities(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent activities from all projects the user has access to (excludes activities for deleted records)."""
|
||||
# Get all projects the user is a member of
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins see recent activity across all projects
|
||||
# (matches shots.py/assets.py).
|
||||
if current_user.role != UserRole.ARTIST:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return activities
|
||||
|
||||
# Get all projects the artist is a member of
|
||||
project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
|
||||
if not project_ids and not current_user.is_admin:
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
# For non-admin users, filter by their project access
|
||||
if not current_user.is_admin:
|
||||
# Get activities from user's projects, excluding deleted records
|
||||
all_activities = []
|
||||
for project_id in project_ids:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
@@ -148,14 +160,6 @@ def get_recent_activities(
|
||||
# Sort by created_at and apply pagination
|
||||
all_activities.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return all_activities[skip:skip + limit]
|
||||
else:
|
||||
# Admin gets all activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return activities
|
||||
|
||||
|
||||
# Admin-only endpoints that include activities for deleted records
|
||||
|
||||
+25
-28
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.asset import Asset, AssetCategory
|
||||
@@ -9,7 +9,8 @@ from models.task import Task, TaskType, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
|
||||
from schemas.task import TaskCreate
|
||||
from utils.auth import get_current_user_from_token
|
||||
from utils.auth import get_current_user_from_token, require_permission
|
||||
from utils.departments import find_owning_department
|
||||
from services.asset_soft_deletion import AssetSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,22 +25,6 @@ def get_current_user_with_db(
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
|
||||
"""Get sort order for task status, including custom statuses."""
|
||||
# Default system status order
|
||||
@@ -135,7 +120,7 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
|
||||
return STANDARD_ASSET_TASK_TYPES + custom_types
|
||||
|
||||
|
||||
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]:
|
||||
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
|
||||
"""Create default tasks for an asset."""
|
||||
created_tasks = []
|
||||
|
||||
@@ -150,7 +135,8 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"Default {task_type} task for {asset.name}",
|
||||
status="not_started"
|
||||
status="not_started",
|
||||
department=find_owning_department(project, task_type) if project else None
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
@@ -374,11 +360,11 @@ async def create_asset(
|
||||
asset: AssetCreate,
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'create'))
|
||||
):
|
||||
"""Create a new asset in a project with optional default tasks"""
|
||||
# Check project access
|
||||
check_project_access(project_id, current_user, db)
|
||||
project = check_project_access(project_id, current_user, db)
|
||||
|
||||
# Check if asset name already exists in project (exclude soft deleted)
|
||||
existing_asset = db.query(Asset).filter(
|
||||
@@ -424,7 +410,7 @@ async def create_asset(
|
||||
task_types = get_default_asset_task_types(asset.category)
|
||||
|
||||
# Create the tasks
|
||||
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db)
|
||||
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
|
||||
task_count = len(created_tasks)
|
||||
|
||||
db.commit()
|
||||
@@ -477,6 +463,15 @@ async def get_asset(
|
||||
|
||||
asset_data = AssetResponse.model_validate(asset)
|
||||
asset_data.task_count = task_count
|
||||
asset_data.task_details = [
|
||||
TaskStatusInfo(
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
task_id=task.id,
|
||||
assigned_user_id=task.assigned_user_id
|
||||
)
|
||||
for task in active_tasks
|
||||
]
|
||||
|
||||
return asset_data
|
||||
|
||||
@@ -527,7 +522,7 @@ async def create_asset_task(
|
||||
asset_id: int,
|
||||
task_type: str, # Changed from TaskType enum to str
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task for an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -560,13 +555,15 @@ async def create_asset_task(
|
||||
|
||||
# Create the task
|
||||
task_name = f"{asset.name} - {task_type.title()}"
|
||||
project = db.query(Project).filter(Project.id == asset.project_id).first()
|
||||
db_task = Task(
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"{task_type.title()} task for {asset.name}",
|
||||
status="not_started"
|
||||
status="not_started",
|
||||
department=find_owning_department(project, task_type)
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
@@ -586,7 +583,7 @@ async def update_asset(
|
||||
asset_id: int,
|
||||
asset_update: AssetUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'edit'))
|
||||
):
|
||||
"""Update an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -642,7 +639,7 @@ async def update_asset(
|
||||
async def get_asset_deletion_info(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'delete'))
|
||||
):
|
||||
"""Get information about what will be deleted when deleting an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -692,7 +689,7 @@ async def get_asset_deletion_info(
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'delete'))
|
||||
):
|
||||
"""Soft delete an asset and all its associated data"""
|
||||
# Exclude soft deleted assets
|
||||
|
||||
@@ -55,6 +55,10 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
from utils.auth import link_system_role
|
||||
link_system_role(new_user, db)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "User registered successfully. Awaiting admin approval.",
|
||||
"user_id": new_user.id
|
||||
|
||||
@@ -16,6 +16,7 @@ from schemas.project import (
|
||||
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@@ -1077,6 +1078,602 @@ async def delete_custom_task_type(
|
||||
return _build_all_task_types_response(db_project)
|
||||
|
||||
|
||||
# Department Management Endpoints
|
||||
|
||||
# Standard departments (read-only): name, whether they apply to shots or assets,
|
||||
# and the task types they own.
|
||||
STANDARD_DEPARTMENTS = [
|
||||
{"name": "layout", "type": "shot", "task_types": ["layout"]},
|
||||
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
|
||||
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
|
||||
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
|
||||
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
|
||||
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
|
||||
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
|
||||
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
|
||||
]
|
||||
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
|
||||
|
||||
|
||||
def _find_custom_department(custom_departments: list, name: str):
|
||||
"""Find a custom department dict by name, or None."""
|
||||
for department in custom_departments:
|
||||
if department["name"] == name:
|
||||
return department
|
||||
return None
|
||||
|
||||
|
||||
def _build_all_departments_response(db_project: Project):
|
||||
"""Helper function to build AllDepartmentsResponse"""
|
||||
from schemas.department import AllDepartmentsResponse, DepartmentInfo
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
|
||||
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
|
||||
|
||||
return AllDepartmentsResponse(
|
||||
departments=standard_infos + custom_infos,
|
||||
standard_departments=standard_infos,
|
||||
custom_departments=custom_infos
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/departments")
|
||||
async def get_all_departments(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get all departments (standard + custom) for a project"""
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
|
||||
async def add_department(
|
||||
project_id: int,
|
||||
department_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Add a new custom department to a project"""
|
||||
from schemas.department import CustomDepartmentCreate
|
||||
|
||||
try:
|
||||
department_create = CustomDepartmentCreate(**department_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
|
||||
if department_create.department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
|
||||
)
|
||||
|
||||
if _find_custom_department(custom_departments, department_create.department):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Department '{department_create.department}' already exists"
|
||||
)
|
||||
|
||||
custom_departments.append({
|
||||
"name": department_create.department,
|
||||
"type": department_create.department_type,
|
||||
"task_types": department_create.task_types
|
||||
})
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to add department"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.put("/{project_id}/departments/{department}")
|
||||
async def update_department(
|
||||
project_id: int,
|
||||
department: str,
|
||||
update_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Update a custom department name, cascading the rename to members and tasks using it"""
|
||||
from schemas.department import CustomDepartmentUpdate
|
||||
from models.task import Task
|
||||
|
||||
try:
|
||||
department_update = CustomDepartmentUpdate(**update_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if department != department_update.old_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Department in URL does not match old_name in request body"
|
||||
)
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department_update.old_name)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department_update.old_name}' not found"
|
||||
)
|
||||
|
||||
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Department '{department_update.new_name}' is a standard department"
|
||||
)
|
||||
|
||||
if (department_update.new_name != department_update.old_name
|
||||
and _find_custom_department(custom_departments, department_update.new_name)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Department '{department_update.new_name}' already exists"
|
||||
)
|
||||
|
||||
existing["name"] = department_update.new_name
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
# Cascade rename to project members using this department
|
||||
members_to_update = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.department_role == department_update.old_name
|
||||
).all()
|
||||
for member in members_to_update:
|
||||
member.department_role = department_update.new_name
|
||||
|
||||
# Cascade rename to tasks using this department
|
||||
tasks_to_update = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department_update.old_name
|
||||
).all()
|
||||
for task in tasks_to_update:
|
||||
task.department = department_update.new_name
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update department"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/departments/{department}")
|
||||
async def delete_department(
|
||||
project_id: int,
|
||||
department: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Delete a custom department (blocked if any member or task is currently using it)"""
|
||||
from models.task import Task
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments cannot be deleted"
|
||||
)
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
|
||||
if not _find_custom_department(custom_departments, department):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
members_using_department = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.department_role == department
|
||||
).all()
|
||||
|
||||
tasks_using_department = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department
|
||||
).all()
|
||||
|
||||
if members_using_department or tasks_using_department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": f"Cannot delete department '{department}' because it is currently in use",
|
||||
"department": department,
|
||||
"member_count": len(members_using_department),
|
||||
"task_count": len(tasks_using_department)
|
||||
}
|
||||
)
|
||||
|
||||
custom_departments = [d for d in custom_departments if d["name"] != department]
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete department"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
|
||||
async def add_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Add a task type to a custom department"""
|
||||
from schemas.department import DepartmentTaskTypeCreate
|
||||
|
||||
try:
|
||||
task_type_create = DepartmentTaskTypeCreate(**task_type_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type_create.task_type in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
|
||||
)
|
||||
|
||||
existing["task_types"].append(task_type_create.task_type)
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to add department task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||
async def rename_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type: str,
|
||||
update_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
|
||||
from schemas.department import DepartmentTaskTypeUpdate
|
||||
from models.task import Task
|
||||
|
||||
try:
|
||||
task_type_update = DepartmentTaskTypeUpdate(**update_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if task_type != task_type_update.old_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Task type in URL does not match old_name in request body"
|
||||
)
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type_update.old_name not in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
|
||||
)
|
||||
|
||||
if (task_type_update.new_name != task_type_update.old_name
|
||||
and task_type_update.new_name in existing["task_types"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
|
||||
)
|
||||
|
||||
existing["task_types"] = [
|
||||
task_type_update.new_name if t == task_type_update.old_name else t
|
||||
for t in existing["task_types"]
|
||||
]
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
# Cascade rename to tasks using this task type within this department
|
||||
tasks_to_update = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department,
|
||||
Task.task_type == task_type_update.old_name
|
||||
).all()
|
||||
for task in tasks_to_update:
|
||||
task.task_type = task_type_update.new_name
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to rename department task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
|
||||
async def delete_department_task_type(
|
||||
project_id: int,
|
||||
department: str,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
|
||||
from models.task import Task
|
||||
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if department in STANDARD_DEPARTMENT_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Standard departments' task types are fixed and cannot be modified"
|
||||
)
|
||||
|
||||
custom_departments = db_project.custom_departments or []
|
||||
existing = _find_custom_department(custom_departments, department)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Custom department '{department}' not found"
|
||||
)
|
||||
|
||||
if task_type not in existing["task_types"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task type '{task_type}' not found in department '{department}'"
|
||||
)
|
||||
|
||||
tasks_using_task_type = db.query(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.department == department,
|
||||
Task.task_type == task_type
|
||||
).all()
|
||||
|
||||
if tasks_using_task_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
|
||||
"department": department,
|
||||
"task_type": task_type,
|
||||
"task_count": len(tasks_using_task_type)
|
||||
}
|
||||
)
|
||||
|
||||
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
|
||||
|
||||
db_project.custom_departments = custom_departments
|
||||
flag_modified(db_project, 'custom_departments')
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete department task type"
|
||||
)
|
||||
|
||||
return _build_all_departments_response(db_project)
|
||||
|
||||
|
||||
# Submission Configuration Endpoints
|
||||
|
||||
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
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
|
||||
|
||||
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -7,7 +7,7 @@ from database import get_db
|
||||
from models.task import Task, Submission, Review, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_permission
|
||||
from utils.notifications import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,7 +66,7 @@ async def get_pending_reviews(
|
||||
query = db.query(Submission).options(
|
||||
joinedload(Submission.user),
|
||||
joinedload(Submission.task).joinedload(Task.project),
|
||||
joinedload(Submission.reviews).joinedload("reviewer")
|
||||
joinedload(Submission.reviews).joinedload(Review.reviewer)
|
||||
).join(Task).filter(
|
||||
Submission.deleted_at.is_(None),
|
||||
Task.deleted_at.is_(None)
|
||||
@@ -126,9 +126,9 @@ async def approve_submission(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('review', 'publish'))
|
||||
):
|
||||
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
|
||||
"""Approve a submission. Requires review:publish permission (or admin)."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
@@ -189,9 +189,9 @@ async def request_retake(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('review', 'retake'))
|
||||
):
|
||||
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
|
||||
"""Request a retake for a submission. Requires review:retake permission (or admin)."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.role import Role, Permission
|
||||
from models.user import User
|
||||
from schemas.role import (
|
||||
RoleResponse, RoleCreate, RoleUpdate, PermissionResponse
|
||||
)
|
||||
from utils.auth import require_admin_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _role_to_response(role: Role) -> RoleResponse:
|
||||
return RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_system=role.is_system,
|
||||
permissions=[PermissionResponse.model_validate(p) for p in role.permissions],
|
||||
user_count=len(role.users),
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RoleResponse])
|
||||
async def list_roles(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""List all roles with their permissions and assigned-user count (admin only)."""
|
||||
roles = db.query(Role).order_by(Role.is_system.desc(), Role.name).all()
|
||||
return [_role_to_response(role) for role in roles]
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=List[PermissionResponse])
|
||||
async def list_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""List the full permission catalog (admin only)."""
|
||||
return db.query(Permission).order_by(Permission.resource, Permission.action).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
role_data: RoleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Create a custom role (admin only)."""
|
||||
existing = db.query(Role).filter(Role.name == role_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{role_data.name}' already exists"
|
||||
)
|
||||
|
||||
permissions = []
|
||||
if role_data.permission_ids:
|
||||
permissions = db.query(Permission).filter(Permission.id.in_(role_data.permission_ids)).all()
|
||||
if len(permissions) != len(set(role_data.permission_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more permission_ids are invalid"
|
||||
)
|
||||
|
||||
role = Role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
is_system=False,
|
||||
permissions=permissions,
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return _role_to_response(role)
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
role_update: RoleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Update a role's name/description/permissions (admin only).
|
||||
|
||||
System roles cannot be renamed or have their description changed, but
|
||||
their permissions ARE editable, same as custom roles.
|
||||
"""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
|
||||
if role.is_system:
|
||||
if role_update.name is not None and role_update.name != role.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System role name cannot be changed"
|
||||
)
|
||||
if role_update.description is not None and role_update.description != role.description:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System role description cannot be changed"
|
||||
)
|
||||
else:
|
||||
if role_update.name is not None:
|
||||
existing = db.query(Role).filter(Role.name == role_update.name, Role.id != role_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{role_update.name}' already exists"
|
||||
)
|
||||
role.name = role_update.name
|
||||
if role_update.description is not None:
|
||||
role.description = role_update.description
|
||||
|
||||
if role_update.permission_ids is not None:
|
||||
permissions = db.query(Permission).filter(Permission.id.in_(role_update.permission_ids)).all()
|
||||
if len(permissions) != len(set(role_update.permission_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more permission_ids are invalid"
|
||||
)
|
||||
role.permissions = permissions
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return _role_to_response(role)
|
||||
|
||||
|
||||
@router.delete("/{role_id}")
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Delete a custom role (admin only). System roles and roles with assigned users cannot be deleted."""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
|
||||
if role.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System roles cannot be deleted"
|
||||
)
|
||||
|
||||
user_count = len(role.users)
|
||||
if user_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{user_count} user(s) still have this role — reassign them first"
|
||||
)
|
||||
|
||||
role.permissions = []
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Role '{role.name}' deleted successfully"}
|
||||
@@ -14,7 +14,7 @@ from schemas.global_settings import (
|
||||
from utils.auth import get_current_user, require_admin_permission
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
||||
# Default upload limit in MB (1GB)
|
||||
DEFAULT_UPLOAD_LIMIT_MB = 1000
|
||||
|
||||
+79
-29
@@ -12,7 +12,8 @@ from schemas.shot import (
|
||||
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
|
||||
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
||||
)
|
||||
from utils.auth import get_current_user_from_token
|
||||
from utils.auth import get_current_user_from_token, require_permission
|
||||
from utils.departments import find_owning_department
|
||||
from services.shot_soft_deletion import ShotSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -27,22 +28,6 @@ def get_current_user_with_db(
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def check_episode_access(episode_id: int, current_user: User, db: Session):
|
||||
"""Check if user has access to the episode and its project."""
|
||||
# Debug logging
|
||||
@@ -147,7 +132,7 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
|
||||
return STANDARD_SHOT_TASK_TYPES + custom_types
|
||||
|
||||
|
||||
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
|
||||
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
|
||||
"""Create default tasks for a shot."""
|
||||
created_tasks = []
|
||||
|
||||
@@ -161,7 +146,8 @@ def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session
|
||||
shot_id=shot.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=task_description
|
||||
description=task_description,
|
||||
department=find_owning_department(project, task_type) if project else None
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
@@ -266,9 +252,17 @@ async def list_shots(
|
||||
)
|
||||
for project in projects:
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
raw = project.custom_task_statuses
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
import json
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raw = []
|
||||
custom_statuses = raw if isinstance(raw, list) else []
|
||||
project_data[project.id] = {
|
||||
'task_types': STANDARD_SHOT_TASK_TYPES + custom_types,
|
||||
'custom_statuses': get_project_custom_statuses(project.id, db)
|
||||
'custom_statuses': custom_statuses
|
||||
}
|
||||
|
||||
# OPTIMIZATION: Group results by shot and aggregate task data efficiently
|
||||
@@ -373,7 +367,7 @@ async def create_shot(
|
||||
episode_id: int,
|
||||
create_default_tasks: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'create'))
|
||||
):
|
||||
"""Create a new shot in an episode"""
|
||||
# Check episode access
|
||||
@@ -428,7 +422,8 @@ async def create_shot(
|
||||
all_task_types = get_all_shot_task_types(episode.project_id, db)
|
||||
# Use default standard types for now (can be customized via project settings)
|
||||
default_task_types = get_default_shot_task_types()
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
|
||||
project = db.query(Project).filter(Project.id == episode.project_id).first()
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
|
||||
db.commit()
|
||||
task_count = len(created_tasks)
|
||||
|
||||
@@ -444,7 +439,7 @@ async def create_shots_bulk(
|
||||
bulk_shot: BulkShotCreate,
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'create'))
|
||||
):
|
||||
"""Create multiple shots with naming pattern and default tasks"""
|
||||
# Check episode access
|
||||
@@ -506,6 +501,7 @@ async def create_shots_bulk(
|
||||
|
||||
created_shots = []
|
||||
total_tasks_created = 0
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
try:
|
||||
# Create all shots - validation already done above
|
||||
@@ -533,7 +529,7 @@ async def create_shots_bulk(
|
||||
# Create default tasks if requested
|
||||
task_count = 0
|
||||
if bulk_shot.create_default_tasks:
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
|
||||
task_count = len(created_tasks)
|
||||
total_tasks_created += task_count
|
||||
|
||||
@@ -606,6 +602,58 @@ async def get_shot(
|
||||
shot_data = ShotResponse.model_validate(shot)
|
||||
shot_data.task_count = task_count
|
||||
|
||||
# Add project_name from episode.project (already eager loaded)
|
||||
if shot.episode and shot.episode.project:
|
||||
shot_data.project_name = shot.episode.project.name
|
||||
|
||||
# Add task status information (similar to list endpoint)
|
||||
project = shot.episode.project if shot.episode else None
|
||||
|
||||
# Get all task types: standard + custom
|
||||
from routers.shots import STANDARD_SHOT_TASK_TYPES
|
||||
project_task_types = list(STANDARD_SHOT_TASK_TYPES) # Start with standard types
|
||||
|
||||
if project and project.custom_shot_task_types:
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
if isinstance(custom_types, list):
|
||||
for ct in custom_types:
|
||||
if isinstance(ct, dict) and 'type' in ct:
|
||||
project_task_types.append(ct['type'])
|
||||
elif isinstance(ct, str):
|
||||
project_task_types.append(ct)
|
||||
|
||||
# Initialize task_status and task_ids dictionaries
|
||||
task_status_dict = {}
|
||||
task_ids_dict = {}
|
||||
task_details_list = []
|
||||
|
||||
# Initialize with default not_started for all project task types
|
||||
for task_type_init in project_task_types:
|
||||
task_status_dict[task_type_init] = "not_started"
|
||||
|
||||
# Build task information from active tasks
|
||||
for task in active_tasks:
|
||||
task_type = task.task_type.value if hasattr(task.task_type, 'value') else task.task_type
|
||||
task_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||
task_id = task.id
|
||||
assigned_user_id = task.assigned_user_id
|
||||
|
||||
# Update task status
|
||||
task_status_dict[task_type] = task_status
|
||||
task_ids_dict[task_type] = task_id
|
||||
|
||||
# Add to task details
|
||||
task_details_list.append(TaskStatusInfo(
|
||||
task_type=task_type,
|
||||
status=task_status,
|
||||
task_id=task_id,
|
||||
assigned_user_id=assigned_user_id
|
||||
))
|
||||
|
||||
shot_data.task_status = task_status_dict
|
||||
shot_data.task_ids = task_ids_dict
|
||||
shot_data.task_details = task_details_list
|
||||
|
||||
return shot_data
|
||||
|
||||
|
||||
@@ -614,7 +662,7 @@ async def create_shot_task(
|
||||
shot_id: int,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task for a shot"""
|
||||
# Exclude soft deleted shots
|
||||
@@ -650,6 +698,7 @@ async def create_shot_task(
|
||||
|
||||
# Create the task
|
||||
task_name = f"{shot.name} - {task_type.title()}"
|
||||
project = db.query(Project).filter(Project.id == shot.project_id).first()
|
||||
db_task = Task(
|
||||
project_id=shot.project_id,
|
||||
episode_id=shot.episode_id,
|
||||
@@ -657,7 +706,8 @@ async def create_shot_task(
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"{task_type.title()} task for {shot.name}",
|
||||
status="not_started"
|
||||
status="not_started",
|
||||
department=find_owning_department(project, task_type)
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
@@ -677,7 +727,7 @@ async def update_shot(
|
||||
shot_id: int,
|
||||
shot_update: ShotUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'edit'))
|
||||
):
|
||||
"""Update a shot"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -758,7 +808,7 @@ async def update_shot(
|
||||
async def get_shot_deletion_info(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'delete'))
|
||||
):
|
||||
"""Get information about what will be deleted when deleting a shot"""
|
||||
# Exclude soft deleted shots
|
||||
@@ -808,7 +858,7 @@ async def get_shot_deletion_info(
|
||||
async def delete_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'delete'))
|
||||
):
|
||||
"""Soft delete a shot and all its associated data"""
|
||||
# Exclude soft deleted shots
|
||||
|
||||
+205
-46
@@ -8,8 +8,8 @@ import json
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review
|
||||
from models.user import User, UserRole, DepartmentRole
|
||||
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
|
||||
from models.user import User, UserRole
|
||||
from models.project import Project, ProjectMember
|
||||
from models.asset import Asset
|
||||
from models.shot import Shot
|
||||
@@ -19,12 +19,13 @@ from schemas.task import (
|
||||
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
||||
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
||||
TaskAttachmentCreate, TaskAttachmentResponse,
|
||||
SubmissionCreate, SubmissionResponse,
|
||||
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
|
||||
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
||||
)
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
||||
from utils.notifications import notification_service
|
||||
from utils.file_handler import file_handler
|
||||
from utils.departments import find_owning_department
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -93,20 +94,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def require_admin_or_coordinator(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require admin permission or coordinator role."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission or Coordinator role required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -262,6 +249,8 @@ async def get_tasks(
|
||||
"name": task.name,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"department": task.department,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"project_name": task.project.name if task.project else None,
|
||||
@@ -343,6 +332,8 @@ async def get_my_tasks(
|
||||
name=task.name,
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
department=task.department,
|
||||
start_date=task.start_date,
|
||||
deadline=task.deadline,
|
||||
project_id=task.project_id,
|
||||
project_name=task.project.name if task.project else "Unknown",
|
||||
@@ -372,7 +363,7 @@ async def get_my_tasks(
|
||||
async def create_task(
|
||||
task: TaskCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task. Only coordinators and users with admin permission can create tasks."""
|
||||
|
||||
@@ -429,6 +420,12 @@ async def create_task(
|
||||
detail=f"Invalid status '{task_data['status']}' for this project"
|
||||
)
|
||||
|
||||
# If the task type belongs to a department, that department wins over
|
||||
# anything explicitly submitted for `department`.
|
||||
owning_department = find_owning_department(project, task_data.get('task_type'))
|
||||
if owning_department:
|
||||
task_data['department'] = owning_department
|
||||
|
||||
# Create task
|
||||
db_task = Task(**task_data)
|
||||
db.add(db_task)
|
||||
@@ -520,7 +517,7 @@ async def bulk_update_task_status(
|
||||
})
|
||||
failed_count += 1
|
||||
continue
|
||||
elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin:
|
||||
elif not user_has_permission(current_user, 'task', 'change_status', db):
|
||||
errors.append({
|
||||
"task_id": task_id,
|
||||
"error": "Insufficient permissions"
|
||||
@@ -597,7 +594,7 @@ async def bulk_update_task_status(
|
||||
async def bulk_assign_tasks(
|
||||
bulk_assignment: BulkAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('assignment', 'edit'))
|
||||
):
|
||||
"""
|
||||
Assign multiple tasks to a user atomically.
|
||||
@@ -694,6 +691,26 @@ async def bulk_assign_tasks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submission-dates", response_model=List[SubmissionDateInfo])
|
||||
async def get_submission_dates(
|
||||
project_id: int = Query(..., description="Project ID to fetch submission dates for"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get a lightweight list of (task_id, submitted_at) for every non-deleted
|
||||
submission on a project's tasks, for rendering submission markers (e.g.
|
||||
on the Schedule Gantt chart) without fetching full submission payloads.
|
||||
"""
|
||||
submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.deleted_at.is_(None),
|
||||
Submission.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions]
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
@@ -732,6 +749,8 @@ async def get_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"department": task.department,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -780,7 +799,7 @@ async def update_task(
|
||||
# Artists can only update status
|
||||
if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
|
||||
raise HTTPException(status_code=403, detail="Artists can only update task status")
|
||||
elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
elif not user_has_permission(current_user, 'task', 'edit', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update tasks")
|
||||
|
||||
# Verify assigned user if being updated
|
||||
@@ -790,6 +809,10 @@ async def update_task(
|
||||
if not assigned_user:
|
||||
raise HTTPException(status_code=404, detail="Assigned user not found")
|
||||
|
||||
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
||||
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
||||
|
||||
# Check if user is a project member
|
||||
project_member = db.query(ProjectMember).filter(
|
||||
and_(
|
||||
@@ -800,6 +823,8 @@ async def update_task(
|
||||
if not project_member:
|
||||
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
|
||||
else:
|
||||
if not user_has_permission(current_user, 'assignment', 'delete', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to unassign this task")
|
||||
task_update.assigned_user_id = None
|
||||
|
||||
# Validate status if being updated
|
||||
@@ -812,6 +837,14 @@ async def update_task(
|
||||
detail=f"Invalid status '{update_data['status']}' for this project"
|
||||
)
|
||||
|
||||
# If the task type is being changed to one owned by a department, that
|
||||
# department wins over anything explicitly submitted for `department`.
|
||||
if 'task_type' in update_data:
|
||||
project = db.query(Project).filter(Project.id == task.project_id).first()
|
||||
owning_department = find_owning_department(project, update_data['task_type'])
|
||||
if owning_department:
|
||||
update_data['department'] = owning_department
|
||||
|
||||
# Update task
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
@@ -851,6 +884,8 @@ async def update_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"department": task.department,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -893,9 +928,10 @@ async def update_task_status(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Permission check
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
||||
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin:
|
||||
elif not user_has_permission(current_user, 'task', 'change_status', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update task status")
|
||||
|
||||
# Validate the status for the task's project
|
||||
@@ -941,6 +977,8 @@ async def update_task_status(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"department": task.department,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -966,7 +1004,7 @@ async def assign_task(
|
||||
task_id: int,
|
||||
assignment: TaskAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Assign a task to a user with department role filtering."""
|
||||
|
||||
@@ -982,6 +1020,12 @@ async def assign_task(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Assigning a previously-unassigned task requires assignment:create;
|
||||
# reassigning an already-assigned task requires assignment:edit.
|
||||
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
||||
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
||||
|
||||
# Verify assigned user exists and is a project member
|
||||
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
|
||||
if not assigned_user:
|
||||
@@ -1000,13 +1044,13 @@ async def assign_task(
|
||||
|
||||
# Check if user's department role matches task type (optional validation)
|
||||
task_to_department_mapping = {
|
||||
"layout": DepartmentRole.LAYOUT,
|
||||
"animation": DepartmentRole.ANIMATION,
|
||||
"lighting": DepartmentRole.LIGHTING,
|
||||
"compositing": DepartmentRole.COMPOSITE,
|
||||
"modeling": DepartmentRole.MODELING,
|
||||
"rigging": DepartmentRole.RIGGING,
|
||||
"surfacing": DepartmentRole.SURFACING,
|
||||
"layout": "layout",
|
||||
"animation": "animation",
|
||||
"lighting": "lighting",
|
||||
"compositing": "composite",
|
||||
"modeling": "modeling",
|
||||
"rigging": "rigging",
|
||||
"surfacing": "surfacing",
|
||||
"simulation": None # Simulation can be handled by multiple departments
|
||||
}
|
||||
|
||||
@@ -1040,6 +1084,8 @@ async def assign_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"department": task.department,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -1064,7 +1110,7 @@ async def assign_task(
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('task', 'delete'))
|
||||
):
|
||||
"""Delete a task. Only coordinators and users with admin permission can delete tasks."""
|
||||
|
||||
@@ -1119,6 +1165,17 @@ async def get_task_notes(
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).order_by(ProductionNote.created_at).all()
|
||||
|
||||
# Filter by note-type view permission (a note whose type the viewer can't
|
||||
# see is dropped entirely - any reply to it is dropped too, since it
|
||||
# would otherwise reference content the viewer isn't allowed to see)
|
||||
can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db)
|
||||
can_view_client = user_has_permission(current_user, 'note', 'view_client', db)
|
||||
notes = [
|
||||
note for note in notes
|
||||
if (note.note_type == NoteType.INTERNAL and can_view_internal)
|
||||
or (note.note_type == NoteType.CLIENT and can_view_client)
|
||||
]
|
||||
|
||||
# Build threaded structure
|
||||
notes_dict = {}
|
||||
root_notes = []
|
||||
@@ -1127,6 +1184,7 @@ async def get_task_notes(
|
||||
note_data = {
|
||||
"id": note.id,
|
||||
"content": note.content,
|
||||
"note_type": note.note_type,
|
||||
"parent_note_id": note.parent_note_id,
|
||||
"task_id": note.task_id,
|
||||
"user_id": note.user_id,
|
||||
@@ -1171,9 +1229,12 @@ async def create_task_note(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Artists can only add notes to their own tasks
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
# Artists can only add notes to their own tasks; everyone else needs note:create
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
|
||||
elif not user_has_permission(current_user, 'note', 'create', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add notes")
|
||||
|
||||
# Verify parent note exists if specified
|
||||
if note.parent_note_id:
|
||||
@@ -1192,6 +1253,7 @@ async def create_task_note(
|
||||
task_id=task_id,
|
||||
user_id=current_user.id,
|
||||
content=note.content,
|
||||
note_type=note.note_type,
|
||||
parent_note_id=note.parent_note_id
|
||||
)
|
||||
db.add(db_note)
|
||||
@@ -1206,6 +1268,7 @@ async def create_task_note(
|
||||
note_data = {
|
||||
"id": db_note.id,
|
||||
"content": db_note.content,
|
||||
"note_type": db_note.note_type,
|
||||
"parent_note_id": db_note.parent_note_id,
|
||||
"task_id": db_note.task_id,
|
||||
"user_id": db_note.user_id,
|
||||
@@ -1244,8 +1307,11 @@ async def update_task_note(
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Users can only update their own notes, unless they have admin permission
|
||||
if note.user_id != current_user.id and not current_user.is_admin:
|
||||
# Editing your own note requires note:edit_self; editing someone else's requires note:edit_other
|
||||
if not current_user.is_admin:
|
||||
is_own = note.user_id == current_user.id
|
||||
action = 'edit_self' if is_own else 'edit_other'
|
||||
if not user_has_permission(current_user, 'note', action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this note")
|
||||
|
||||
note.content = note_update.content
|
||||
@@ -1255,6 +1321,7 @@ async def update_task_note(
|
||||
note_data = {
|
||||
"id": note.id,
|
||||
"content": note.content,
|
||||
"note_type": note.note_type,
|
||||
"parent_note_id": note.parent_note_id,
|
||||
"task_id": note.task_id,
|
||||
"user_id": note.user_id,
|
||||
@@ -1290,8 +1357,11 @@ async def delete_task_note(
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Users can only delete their own notes, unless they have admin permission
|
||||
if note.user_id != current_user.id and not current_user.is_admin:
|
||||
# Deleting your own note requires note:delete_self; deleting someone else's requires note:delete_other
|
||||
if not current_user.is_admin:
|
||||
is_own = note.user_id == current_user.id
|
||||
action = 'delete_self' if is_own else 'delete_other'
|
||||
if not user_has_permission(current_user, 'note', action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this note")
|
||||
|
||||
db.delete(note)
|
||||
@@ -1368,9 +1438,12 @@ async def upload_task_attachment(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Artists can only upload attachments to their own tasks
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
# Artists can only upload attachments to their own tasks; everyone else needs upload:create
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
|
||||
elif not user_has_permission(current_user, 'upload', 'create', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to upload attachments")
|
||||
|
||||
# Validate file using file handler
|
||||
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
|
||||
@@ -1443,9 +1516,9 @@ async def delete_task_attachment(
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Users can only delete their own attachments, unless they're admin/coordinator
|
||||
if (attachment.user_id != current_user.id and
|
||||
not current_user.is_admin and current_user.role != UserRole.COORDINATOR):
|
||||
# Users can only delete their own attachments, unless they have admin permission or upload:delete
|
||||
if (attachment.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'upload', 'delete', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
|
||||
|
||||
# Delete file from filesystem using file handler
|
||||
@@ -1534,8 +1607,9 @@ async def submit_work(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Only assigned artist can submit work
|
||||
if task.assigned_user_id != current_user.id:
|
||||
# Only the assigned artist can submit work, unless the caller holds submission:create
|
||||
if (task.assigned_user_id != current_user.id
|
||||
and not user_has_permission(current_user, 'submission', 'create', db)):
|
||||
raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
|
||||
|
||||
# Validate file using file handler
|
||||
@@ -1600,3 +1674,88 @@ async def submit_work(
|
||||
}
|
||||
|
||||
return SubmissionResponse(**submission_data)
|
||||
|
||||
|
||||
@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse)
|
||||
async def update_task_submission(
|
||||
task_id: int,
|
||||
submission_id: int,
|
||||
submission_update: SubmissionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a submission's notes. Users can only update their own submissions."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.user)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.task_id == task_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Editing your own submission requires submission:edit_self; editing
|
||||
# someone else's requires submission:edit_other
|
||||
if not current_user.is_admin:
|
||||
is_own = submission.user_id == current_user.id
|
||||
action = 'edit_self' if is_own else 'edit_other'
|
||||
if not user_has_permission(current_user, 'submission', action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this submission")
|
||||
|
||||
if submission_update.notes is not None:
|
||||
submission.notes = submission_update.notes
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
|
||||
submission_data = {
|
||||
"id": submission.id,
|
||||
"task_id": submission.task_id,
|
||||
"user_id": submission.user_id,
|
||||
"file_path": submission.file_path,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"user_first_name": submission.user.first_name,
|
||||
"user_last_name": submission.user.last_name,
|
||||
"latest_review": None,
|
||||
"download_url": f"/files/submissions/{submission.id}",
|
||||
"thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None,
|
||||
"stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None
|
||||
}
|
||||
|
||||
return SubmissionResponse(**submission_data)
|
||||
|
||||
|
||||
@router.delete("/{task_id}/submissions/{submission_id}")
|
||||
async def delete_task_submission(
|
||||
task_id: int,
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Soft delete a submission. Users can only delete their own submissions."""
|
||||
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.task_id == task_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Deleting your own submission requires submission:delete_self; deleting
|
||||
# someone else's requires submission:delete_other
|
||||
if not current_user.is_admin:
|
||||
is_own = submission.user_id == current_user.id
|
||||
action = 'delete_self' if is_own else 'delete_other'
|
||||
if not user_has_permission(current_user, 'submission', action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
|
||||
|
||||
submission.deleted_at = datetime.utcnow()
|
||||
submission.deleted_by = current_user.id
|
||||
db.commit()
|
||||
|
||||
return {"message": "Submission deleted successfully"}
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from typing import List, Optional
|
||||
from passlib.context import CryptContext
|
||||
from pathlib import Path
|
||||
@@ -12,7 +12,9 @@ from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.project import ProjectMember
|
||||
from models.task import Task
|
||||
from models.role import Role
|
||||
from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange
|
||||
from schemas.role import UserRolesUpdate
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -106,6 +108,42 @@ async def update_user_role(
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/roles", response_model=dict)
|
||||
async def update_user_roles(
|
||||
user_id: int,
|
||||
roles_data: UserRolesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Replace a user's full set of assigned custom roles (Admin permission required).
|
||||
|
||||
This is the new multi-role system and is independent of the legacy
|
||||
single `role` field updated by PUT /{user_id}/role above.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
roles = db.query(Role).filter(Role.id.in_(roles_data.role_ids)).all()
|
||||
if len(roles) != len(set(roles_data.role_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more role_ids are invalid"
|
||||
)
|
||||
|
||||
user.roles = roles
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} roles updated",
|
||||
"user_id": user.id,
|
||||
"role_ids": [role.id for role in user.roles]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/admin", response_model=dict)
|
||||
async def update_user_admin_permission(
|
||||
user_id: int,
|
||||
@@ -149,7 +187,7 @@ async def list_users(
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""List all users (Admin and Coordinator only)."""
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
users = db.query(User).options(selectinload(User.roles)).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@@ -169,7 +207,10 @@ async def get_current_user_profile(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's profile."""
|
||||
from utils.auth import compute_effective_permissions
|
||||
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
current_user.permissions = compute_effective_permissions(current_user, db)
|
||||
return current_user
|
||||
|
||||
|
||||
@@ -214,7 +255,7 @@ async def get_user(
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""Get user by ID (Admin and Coordinator only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
user = db.query(User).options(selectinload(User.roles)).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -256,6 +297,11 @@ async def admin_create_user(
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
from utils.auth import link_system_role
|
||||
link_system_role(new_user, db)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ class AssetUpdate(BaseModel):
|
||||
status: Optional[AssetStatus] = None
|
||||
|
||||
|
||||
class TaskStatusInfo(BaseModel):
|
||||
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
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetResponse(AssetBase):
|
||||
id: int
|
||||
project_id: int
|
||||
@@ -33,18 +40,12 @@ class AssetResponse(AssetBase):
|
||||
|
||||
# Summary information
|
||||
task_count: int = 0
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskStatusInfo(BaseModel):
|
||||
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
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetListResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -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
|
||||
|
||||
from models.project import ProjectStatus, ProjectType
|
||||
from models.user import DepartmentRole
|
||||
|
||||
|
||||
# Technical Specifications Schemas
|
||||
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
|
||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||
return v
|
||||
|
||||
@validator('delivery_movie_specs_by_department')
|
||||
def validate_delivery_movie_specs_by_department(cls, v):
|
||||
if v is None:
|
||||
return {}
|
||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
||||
for dept in v.keys():
|
||||
if dept not in allowed_departments:
|
||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
||||
return v
|
||||
|
||||
|
||||
# Default delivery movie specifications per department
|
||||
DEFAULT_DELIVERY_MOVIE_SPECS = {
|
||||
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
|
||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||
return v
|
||||
|
||||
@validator('delivery_movie_specs_by_department')
|
||||
def validate_delivery_movie_specs_by_department(cls, v):
|
||||
if v is None:
|
||||
return {}
|
||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
||||
for dept in v.keys():
|
||||
if dept not in allowed_departments:
|
||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
||||
return v
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
|
||||
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
|
||||
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||
return v
|
||||
|
||||
@validator('delivery_movie_specs_by_department')
|
||||
def validate_delivery_movie_specs_by_department(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
|
||||
for dept in v.keys():
|
||||
if dept not in allowed_departments:
|
||||
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
|
||||
return v
|
||||
|
||||
|
||||
class ProjectMemberBase(BaseModel):
|
||||
user_id: int
|
||||
department_role: Optional[DepartmentRole] = None
|
||||
department_role: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectMemberCreate(ProjectMemberBase):
|
||||
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
|
||||
|
||||
|
||||
class ProjectMemberUpdate(BaseModel):
|
||||
department_role: Optional[DepartmentRole] = None
|
||||
department_role: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectMemberResponse(ProjectMemberBase):
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
id: int
|
||||
resource: str
|
||||
action: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleSummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
is_system: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_system: bool
|
||||
permissions: List[PermissionResponse]
|
||||
user_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permission_ids: List[int] = []
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
permission_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class UserRolesUpdate(BaseModel):
|
||||
role_ids: List[int]
|
||||
+13
-8
@@ -28,6 +28,14 @@ class ShotUpdate(BaseModel):
|
||||
project_id: Optional[int] = Field(None, description="Project ID - must match episode's project")
|
||||
|
||||
|
||||
class TaskStatusInfo(BaseModel):
|
||||
"""Task status information for table display"""
|
||||
task_type: str # String to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class ShotResponse(ShotBase):
|
||||
id: int
|
||||
project_id: int # Make required in response
|
||||
@@ -41,18 +49,15 @@ class ShotResponse(ShotBase):
|
||||
# Optional computed field for display
|
||||
project_name: Optional[str] = Field(None, description="Project name for display purposes")
|
||||
|
||||
# Task status information for detail display
|
||||
task_status: Dict[str, Optional[str]] = Field(default_factory=dict, description="Task status by task type")
|
||||
task_ids: Dict[str, int] = Field(default_factory=dict, description="Task IDs by task type")
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskStatusInfo(BaseModel):
|
||||
"""Task status information for table display"""
|
||||
task_type: str # String to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class ShotListResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -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)
|
||||
+23
-4
@@ -3,16 +3,17 @@ from typing import Optional, List
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
|
||||
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType
|
||||
from models.user import DepartmentRole
|
||||
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||
start_date: Optional[date] = None
|
||||
deadline: Optional[date] = None
|
||||
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
|
||||
department: Optional[str] = None # Standard or project-custom department, independent of assignee
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
@@ -27,8 +28,10 @@ class TaskUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -68,6 +71,8 @@ class TaskListResponse(BaseModel):
|
||||
name: str
|
||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
department: Optional[str] = None # Standard or project-custom department, independent of assignee
|
||||
start_date: Optional[date] = None
|
||||
deadline: Optional[date] = None
|
||||
project_id: int
|
||||
project_name: str
|
||||
@@ -94,7 +99,7 @@ class ProductionNoteBase(BaseModel):
|
||||
|
||||
|
||||
class ProductionNoteCreate(ProductionNoteBase):
|
||||
pass
|
||||
note_type: NoteType = NoteType.INTERNAL
|
||||
|
||||
|
||||
class ProductionNoteUpdate(BaseModel):
|
||||
@@ -105,6 +110,7 @@ class ProductionNoteResponse(ProductionNoteBase):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
note_type: NoteType
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -162,6 +168,10 @@ class SubmissionCreate(SubmissionBase):
|
||||
pass
|
||||
|
||||
|
||||
class SubmissionUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SubmissionResponse(SubmissionBase):
|
||||
id: int
|
||||
task_id: int
|
||||
@@ -187,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SubmissionDateInfo(BaseModel):
|
||||
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
|
||||
task_id: int
|
||||
submitted_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Review schemas
|
||||
class ReviewBase(BaseModel):
|
||||
decision: ReviewDecision
|
||||
@@ -214,7 +233,7 @@ class ReviewResponse(ReviewBase):
|
||||
# Bulk action schemas
|
||||
class BulkStatusUpdate(BaseModel):
|
||||
task_ids: List[int] = Field(..., min_length=1)
|
||||
status: TaskStatus
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
|
||||
|
||||
class BulkAssignment(BaseModel):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from models.user import UserRole
|
||||
from schemas.role import RoleSummary
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
@@ -30,6 +31,8 @@ class UserResponse(UserBase):
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
roles: List[RoleSummary] = []
|
||||
permissions: Optional[List[str]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -302,10 +302,9 @@ class ActivityService:
|
||||
"""Get activities excluding those related to deleted records."""
|
||||
from sqlalchemy import and_, or_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from models.task import Task
|
||||
from models.task import Task, Submission
|
||||
from models.shot import Shot
|
||||
from models.asset import Asset
|
||||
from models.submission import Submission
|
||||
|
||||
query = db.query(Activity)
|
||||
|
||||
|
||||
@@ -244,6 +244,101 @@ def require_admin_permission():
|
||||
return admin_checker
|
||||
|
||||
|
||||
def compute_effective_permissions(current_user, db: Session) -> list:
|
||||
"""Return the current user's effective permissions as "resource:action" strings.
|
||||
|
||||
is_admin gets every permission in the catalog; otherwise the union of all
|
||||
permissions granted by any of the user's assigned roles (most-permissive-wins).
|
||||
"""
|
||||
from models.role import Permission, role_permissions, user_roles
|
||||
|
||||
if current_user.is_admin:
|
||||
rows = db.query(Permission.resource, Permission.action).all()
|
||||
else:
|
||||
rows = (
|
||||
db.query(Permission.resource, Permission.action)
|
||||
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
|
||||
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
|
||||
.filter(user_roles.c.user_id == current_user.id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return [f"{resource}:{action}" for resource, action in rows]
|
||||
|
||||
|
||||
def link_system_role(user, db: Session) -> None:
|
||||
"""Link a newly-created user to the system Role matching their legacy
|
||||
`role` value, mirroring the one-time migration backfill so new users
|
||||
start with the same baseline permissions as already-migrated ones.
|
||||
No-op if the matching system role doesn't exist or is already linked.
|
||||
Caller is responsible for commit/refresh.
|
||||
"""
|
||||
from models.role import Role
|
||||
|
||||
role = db.query(Role).filter(Role.name == user.role.value, Role.is_system == True).first()
|
||||
if role and role not in user.roles:
|
||||
user.roles.append(role)
|
||||
|
||||
|
||||
def user_has_permission(current_user, resource: str, action: str, db: Session) -> bool:
|
||||
"""Most-permissive-wins check across all of current_user's assigned roles.
|
||||
|
||||
is_admin is a universal bypass and never needs a role/permission row.
|
||||
"""
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
|
||||
from models.role import Permission, role_permissions, user_roles
|
||||
|
||||
exists = (
|
||||
db.query(Permission.id)
|
||||
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
|
||||
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
|
||||
.filter(
|
||||
user_roles.c.user_id == current_user.id,
|
||||
Permission.resource == resource,
|
||||
Permission.action == action,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
|
||||
def require_permission(resource: str, action: str):
|
||||
"""Dependency factory requiring the current user to hold resource:action
|
||||
via any assigned role (or be an admin). Usage: Depends(require_permission('shot', 'create'))
|
||||
"""
|
||||
def permission_checker(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(lambda: None)
|
||||
):
|
||||
from database import get_db
|
||||
|
||||
# Get database session if not provided
|
||||
if db is None:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not user_has_permission(current_user, resource, action, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {resource}:{action}"
|
||||
)
|
||||
return current_user
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not user_has_permission(current_user, resource, action, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {resource}:{action}"
|
||||
)
|
||||
return current_user
|
||||
return permission_checker
|
||||
|
||||
|
||||
def create_role_dependency(required_roles: list):
|
||||
"""Create a dependency that requires specific user roles with proper DB injection."""
|
||||
def role_checker(
|
||||
|
||||
@@ -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_FORMATS = {
|
||||
# Video formats
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm',
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||
# Image formats
|
||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||
# Document formats
|
||||
'.pdf', '.txt', '.doc', '.docx',
|
||||
# Archive formats
|
||||
'.zip', '.rar', '.7z'
|
||||
'.zip', '.rar', '.7z',
|
||||
# Scene formats
|
||||
'.ma', '.usd', '.usda', '.usdc'
|
||||
}
|
||||
|
||||
# File size limits (in bytes)
|
||||
@@ -35,7 +37,7 @@ class FileHandler:
|
||||
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
|
||||
|
||||
# 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_SIZE = (200, 200)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Assignment Popover Implementation - Final Status
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
The EditableTaskStatus component has been successfully updated with comprehensive fixes for the assignment popover functionality. All major issues have been addressed.
|
||||
|
||||
## 🔧 Key Fixes Applied
|
||||
|
||||
### 1. Event Handling
|
||||
- **Event Prevention Wrapper**: Added `<div @click.stop>` around the popover to prevent table row click interference
|
||||
- **Proper Event Propagation**: Removed custom click handlers that were conflicting with Popover's natural behavior
|
||||
|
||||
### 2. Z-Index and Positioning
|
||||
- **Button Z-Index**: Added `z-10` to the assignment button for proper layering
|
||||
- **Popover Z-Index**: Added `z-50` to popover content for top-level display
|
||||
- **Positioning**: Added `side="bottom"` and `:side-offset="4"` for better popover placement
|
||||
|
||||
### 3. Data Loading Strategy
|
||||
- **Watcher-Based Loading**: Project members are loaded when popover opens using Vue watcher
|
||||
- **Preload Strategy**: Members are also preloaded on component mount for faster access
|
||||
- **Loading States**: Proper loading indicators and error handling
|
||||
|
||||
### 4. Debug and Error Handling
|
||||
- **Debug Information**: Shows project ID and member count in popover
|
||||
- **Loading States**: Spinner and "Loading members..." message
|
||||
- **Error States**: "No project members found" with retry button
|
||||
- **Console Logging**: Comprehensive logging for debugging
|
||||
|
||||
### 5. Assignment Logic
|
||||
- **Task Creation**: Automatically creates tasks if they don't exist
|
||||
- **Assignment API**: Uses `taskService.assignTask()` for user assignment
|
||||
- **Unassignment API**: Uses `taskService.updateTask()` with `assigned_user_id: 0`
|
||||
- **Visual Feedback**: Avatar updates to show assigned user
|
||||
- **Notifications**: Success toast messages for assignment/unassignment
|
||||
|
||||
## 🎯 Component Features
|
||||
|
||||
### Visual Elements
|
||||
- **Unassigned Tasks**: Show 👤 User icon
|
||||
- **Assigned Tasks**: Show 🔵 Avatar with user initials
|
||||
- **Loading State**: Spinner overlay during updates
|
||||
- **Disabled State**: Button disabled during API calls
|
||||
|
||||
### Popover Content
|
||||
- **Header**: "Assign Task" title
|
||||
- **Debug Info**: Project ID and member count
|
||||
- **Loading State**: Spinner with "Loading members..." text
|
||||
- **Error State**: "No project members found" with retry button
|
||||
- **Unassign Option**: "Unassign" button with UserX icon
|
||||
- **Member List**: Scrollable list with avatars, names, and roles
|
||||
|
||||
### Functionality
|
||||
- **Smart Loading**: Only loads members when popover opens (if not already loaded)
|
||||
- **Retry Logic**: Retry button for failed member loading
|
||||
- **Assignment**: Click member to assign task
|
||||
- **Unassignment**: Click "Unassign" to remove assignment
|
||||
- **Auto-Close**: Popover closes after successful assignment
|
||||
- **Toast Notifications**: Success/error messages
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### Manual Testing Steps
|
||||
1. **Navigate to Shots Table**: Go to any project's shots page in table view
|
||||
2. **Locate Assignment Buttons**: Look for 👤 or 🔵 icons in task columns
|
||||
3. **Click Assignment Button**: Verify popover opens immediately
|
||||
4. **Check Content Loading**: Verify debug info, loading states, and member list
|
||||
5. **Test Assignment**: Click a member and verify assignment works
|
||||
6. **Test Unassignment**: Click "Unassign" and verify it works
|
||||
7. **Check Visual Updates**: Verify button shows correct icon/avatar
|
||||
8. **Verify Notifications**: Check for success toast messages
|
||||
|
||||
### Console Verification
|
||||
Expected console messages when clicking assignment button:
|
||||
```
|
||||
Popover state changed: true
|
||||
Loading project members when popover opens
|
||||
Loading project members for project: [ID]
|
||||
Loaded project members: [...]
|
||||
```
|
||||
|
||||
### Network Verification
|
||||
Expected API calls:
|
||||
- `GET /projects/{id}/members` - Load project members
|
||||
- `POST /shots/{id}/tasks` - Create task if needed
|
||||
- `PUT /tasks/{id}/assign` - Assign task to user
|
||||
- `PUT /tasks/{id}` - Update task (for unassignment)
|
||||
|
||||
## 🚀 Ready for Production
|
||||
|
||||
The assignment popover functionality is now fully implemented and ready for use. The component includes:
|
||||
|
||||
- ✅ Robust event handling
|
||||
- ✅ Proper z-index management
|
||||
- ✅ Smart data loading
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Visual feedback and notifications
|
||||
- ✅ Debug information for troubleshooting
|
||||
- ✅ Full assignment/unassignment workflow
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
If issues occur, check:
|
||||
1. Browser console for JavaScript errors
|
||||
2. Network tab for failed API calls
|
||||
3. User permissions (coordinator/admin required)
|
||||
4. Backend service availability
|
||||
5. Project member data availability
|
||||
|
||||
The implementation is comprehensive and should handle all common use cases and edge cases gracefully.
|
||||
@@ -1,124 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clear Auth & Reload</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 10px 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.success {
|
||||
background: #28a745;
|
||||
}
|
||||
.success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
.info {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔧 Fix Shot Detail 403 Error</h1>
|
||||
|
||||
<div class="info">
|
||||
<h3>Current Issue:</h3>
|
||||
<p>You're getting 403 errors when clicking on shots, even though you're logged in as admin.</p>
|
||||
<p><strong>Solution:</strong> Clear your authentication tokens and login again.</p>
|
||||
</div>
|
||||
|
||||
<h2>Step 1: Check Current Auth State</h2>
|
||||
<button onclick="checkAuth()">Check Auth</button>
|
||||
<pre id="authState"></pre>
|
||||
|
||||
<h2>Step 2: Clear All Auth Data</h2>
|
||||
<button class="danger" onclick="clearAuth()">Clear Auth Tokens</button>
|
||||
<pre id="clearResult"></pre>
|
||||
|
||||
<h2>Step 3: Reload Application</h2>
|
||||
<button class="success" onclick="reloadApp()">Reload & Login Again</button>
|
||||
|
||||
<script>
|
||||
function checkAuth() {
|
||||
const authState = {
|
||||
token: localStorage.getItem('token'),
|
||||
refreshToken: localStorage.getItem('refreshToken'),
|
||||
user: localStorage.getItem('user')
|
||||
};
|
||||
|
||||
document.getElementById('authState').textContent = JSON.stringify(authState, null, 2);
|
||||
|
||||
if (authState.user) {
|
||||
try {
|
||||
const user = JSON.parse(authState.user);
|
||||
console.log('User data:', user);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
// Clear all auth-related items
|
||||
const keysToRemove = [
|
||||
'token',
|
||||
'refreshToken',
|
||||
'user',
|
||||
'access_token',
|
||||
'refresh_token'
|
||||
];
|
||||
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
document.getElementById('clearResult').textContent = '✓ All auth tokens cleared!\n\nCleared items:\n' + keysToRemove.join('\n');
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
// Clear auth first
|
||||
clearAuth();
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
window.location.href = 'http://localhost:5173/login';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Auto-check on load
|
||||
window.onload = checkAuth;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,175 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Clear Expired Tokens</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #1a365d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.warning {
|
||||
background: #fef3c7;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #92400e;
|
||||
}
|
||||
.success {
|
||||
background: #d1fae5;
|
||||
border: 2px solid #10b981;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #065f46;
|
||||
}
|
||||
button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.info {
|
||||
background: #eff6ff;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #1e40af;
|
||||
}
|
||||
.token-info {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 Clear Expired Tokens</h1>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Token Expiration Issue</strong><br>
|
||||
Your JWT tokens have expired. This is causing the "Signature has expired" error when trying to log in.
|
||||
</div>
|
||||
|
||||
<div id="tokenStatus" class="info">
|
||||
<strong>Current Token Status:</strong>
|
||||
<div id="tokenDetails"></div>
|
||||
</div>
|
||||
|
||||
<button id="clearBtn" onclick="clearTokens()">Clear Expired Tokens</button>
|
||||
|
||||
<div id="result" style="display: none;"></div>
|
||||
|
||||
<div class="info" style="margin-top: 30px;">
|
||||
<strong>What this does:</strong><br>
|
||||
• Removes access_token from localStorage<br>
|
||||
• Removes refresh_token from localStorage<br>
|
||||
• Allows you to log in fresh with valid credentials
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<strong>After clearing tokens:</strong><br>
|
||||
1. Click the "Clear Expired Tokens" button above<br>
|
||||
2. Navigate to <a href="http://localhost:5173/login">http://localhost:5173/login</a><br>
|
||||
3. Log in with your credentials<br>
|
||||
4. You should now be able to access the application
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function checkTokens() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const detailsDiv = document.getElementById('tokenDetails');
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
detailsDiv.innerHTML = '<div class="token-info">✅ No tokens found - you can log in fresh!</div>';
|
||||
document.getElementById('clearBtn').disabled = true;
|
||||
document.getElementById('clearBtn').textContent = 'No Tokens to Clear';
|
||||
} else {
|
||||
let html = '';
|
||||
if (accessToken) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(accessToken.split('.')[1]));
|
||||
const exp = new Date(payload.exp * 1000);
|
||||
const now = new Date();
|
||||
const expired = exp < now;
|
||||
html += `<div class="token-info">
|
||||
<strong>Access Token:</strong> ${expired ? '❌ EXPIRED' : '✅ Valid'}<br>
|
||||
Expires: ${exp.toLocaleString()}<br>
|
||||
${expired ? `Expired ${Math.floor((now - exp) / (1000 * 60 * 60 * 24))} days ago` : ''}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
html += '<div class="token-info">❌ Access Token: Invalid format</div>';
|
||||
}
|
||||
}
|
||||
if (refreshToken) {
|
||||
html += '<div class="token-info">🔄 Refresh Token: Present (likely expired)</div>';
|
||||
}
|
||||
detailsDiv.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
showResult('No tokens to clear!', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
|
||||
showResult('✅ Tokens cleared successfully! You can now log in fresh.', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
checkTokens();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function showResult(message, type) {
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.className = type;
|
||||
resultDiv.innerHTML = message;
|
||||
resultDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Check tokens on page load
|
||||
checkTokens();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,169 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Final Implementation Verification</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.success { color: #22c55e; font-weight: bold; }
|
||||
.error { color: #ef4444; font-weight: bold; }
|
||||
.info { color: #3b82f6; font-weight: bold; }
|
||||
.step {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #22c55e;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fef3c7;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.checklist {
|
||||
background-color: #f0f9ff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.checklist ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.checklist li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✅ Final Implementation Verification</h1>
|
||||
|
||||
<div class="step">
|
||||
<h2>🎯 All Requirements Successfully Implemented</h2>
|
||||
<p>The task status implementation has been completed with all requested improvements:</p>
|
||||
</div>
|
||||
|
||||
<div class="checklist">
|
||||
<h3>✅ 1. Consistent Task Status Badge Width</h3>
|
||||
<ul>
|
||||
<li><strong>Implementation:</strong> Fixed width of <span class="highlight">130px</span> for all task status badges</li>
|
||||
<li><strong>Location:</strong> <code>EditableTaskStatus.vue</code> - SelectTrigger class: <code>w-[130px]</code></li>
|
||||
<li><strong>Benefit:</strong> Perfect column alignment, no layout shifts when status changes</li>
|
||||
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="checklist">
|
||||
<h3>✅ 2. Column Visibility Dropdown Menu</h3>
|
||||
<ul>
|
||||
<li><strong>New Component:</strong> <code>ColumnVisibilityControl.vue</code> created</li>
|
||||
<li><strong>Features:</strong>
|
||||
<ul>
|
||||
<li>Individual control for each column (Name, Category, Status, Task Count)</li>
|
||||
<li>Separate controls for task status columns (Modeling, Surfacing, Rigging)</li>
|
||||
<li>Additional columns (Description, Updated)</li>
|
||||
<li>Organized dropdown with sections and labels</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Persistence:</strong> Settings saved in session storage with deep watching</li>
|
||||
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="checklist">
|
||||
<h3>✅ 3. Removed Show Task Status Checkbox</h3>
|
||||
<ul>
|
||||
<li><strong>Removed:</strong> <code>TaskStatusToggle.vue</code> component deleted</li>
|
||||
<li><strong>Replaced:</strong> Simple checkbox replaced with comprehensive column control</li>
|
||||
<li><strong>Updated:</strong> AssetBrowser.vue updated to use new ColumnVisibilityControl</li>
|
||||
<li><strong>Cleaned:</strong> All references to showTaskStatus removed</li>
|
||||
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🧪 How to Test</h2>
|
||||
<ol>
|
||||
<li><strong>Open Application:</strong> <span class="highlight">http://localhost:5174</span></li>
|
||||
<li><strong>Login:</strong> <span class="highlight">admin@vfx.com / admin123</span></li>
|
||||
<li><strong>Navigate:</strong> Project → Assets tab → List view</li>
|
||||
<li><strong>Verify Badge Width:</strong> All task status badges should be exactly the same width</li>
|
||||
<li><strong>Test Column Control:</strong> Click "Columns" dropdown to show/hide individual columns</li>
|
||||
<li><strong>Test Persistence:</strong> Change settings, refresh page, settings should be remembered</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🎨 Visual Improvements</h2>
|
||||
<ul>
|
||||
<li><strong>Professional Layout:</strong> Consistent column widths and alignment</li>
|
||||
<li><strong>Flexible Display:</strong> Users can customize their view completely</li>
|
||||
<li><strong>Smart Columns:</strong> Rigging column only shows when relevant assets exist</li>
|
||||
<li><strong>Better UX:</strong> Granular control instead of simple on/off toggle</li>
|
||||
<li><strong>Clean Interface:</strong> Organized dropdown with clear sections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🔧 Technical Details</h2>
|
||||
<ul>
|
||||
<li><strong>Badge Width:</strong> <code>w-[130px]</code> ensures consistent sizing</li>
|
||||
<li><strong>Column Headers:</strong> Fixed width <code>w-[140px]</code> for task status columns</li>
|
||||
<li><strong>State Management:</strong> <code>visibleColumns</code> reactive object with session persistence</li>
|
||||
<li><strong>Smart Display:</strong> <code>hasRiggingAssets</code> computed property for conditional rendering</li>
|
||||
<li><strong>Deep Watching:</strong> Column changes automatically saved to session storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="status" class="step">
|
||||
<h2>🚀 System Status</h2>
|
||||
<p id="status-text">Checking system readiness...</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🎉 Implementation Complete!</h2>
|
||||
<p>All requested features have been successfully implemented and tested. The asset browser now provides:</p>
|
||||
<ul>
|
||||
<li>✅ Consistent task status badge widths</li>
|
||||
<li>✅ Comprehensive column visibility control</li>
|
||||
<li>✅ Professional, customizable interface</li>
|
||||
<li>✅ Persistent user preferences</li>
|
||||
<li>✅ Error-free operation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function checkStatus() {
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/health');
|
||||
if (response.ok) {
|
||||
statusText.innerHTML = '<span class="success">✅ System Ready - All features available for testing!</span>';
|
||||
} else {
|
||||
statusText.innerHTML = '<span class="error">❌ Backend issue: ' + response.status + '</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
statusText.innerHTML = '<span class="error">❌ Backend not running. Start with: cd backend && python main.py</span>';
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,200 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Implementation Summary</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.success { color: #22c55e; font-weight: bold; }
|
||||
.error { color: #ef4444; font-weight: bold; }
|
||||
.info { color: #3b82f6; font-weight: bold; }
|
||||
.step {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #22c55e;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fef3c7;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.checklist {
|
||||
background-color: #f0f9ff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.checklist ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.checklist li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.code {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✅ Implementation Summary - Asset Browser Enhancements</h1>
|
||||
|
||||
<div class="step">
|
||||
<h2>🔧 Issues Fixed</h2>
|
||||
<ul>
|
||||
<li><strong>Columns Dropdown Not Working:</strong> Fixed duplicate import in AssetBrowser.vue</li>
|
||||
<li><strong>Task Status Badge Width:</strong> Standardized to 130px for consistent alignment</li>
|
||||
<li><strong>Column Control:</strong> Implemented comprehensive dropdown menu for individual column visibility</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="checklist">
|
||||
<h3>✅ New Features Implemented</h3>
|
||||
|
||||
<h4>1. Fixed Column Visibility Control</h4>
|
||||
<ul>
|
||||
<li>✅ <strong>ColumnVisibilityControl.vue:</strong> Comprehensive dropdown menu component</li>
|
||||
<li>✅ <strong>Individual Controls:</strong> Name, Category, Status, Thumbnail, Task Status, Description, Updated</li>
|
||||
<li>✅ <strong>Organized Sections:</strong> Basic columns, Task Status columns, Other columns</li>
|
||||
<li>✅ <strong>Session Persistence:</strong> User preferences saved automatically</li>
|
||||
</ul>
|
||||
|
||||
<h4>2. Thumbnail Column Added</h4>
|
||||
<ul>
|
||||
<li>✅ <strong>Thumbnail Column:</strong> Visual preview column with placeholder icons</li>
|
||||
<li>✅ <strong>ThumbnailToggle.vue:</strong> Separate toggle switch for thumbnail visibility</li>
|
||||
<li>✅ <strong>Sync Functionality:</strong> Thumbnail toggle syncs with column visibility control</li>
|
||||
<li>✅ <strong>Default Hidden:</strong> Thumbnails hidden by default to keep table clean</li>
|
||||
</ul>
|
||||
|
||||
<h4>3. Task Count Column Removed</h4>
|
||||
<ul>
|
||||
<li>✅ <strong>Removed Task Count:</strong> Eliminated task count column from asset table</li>
|
||||
<li>✅ <strong>Focus on Individual Status:</strong> Emphasis on individual task status rather than counts</li>
|
||||
<li>✅ <strong>Cleaner Interface:</strong> More space for important columns</li>
|
||||
</ul>
|
||||
|
||||
<h4>4. Consistent Badge Width</h4>
|
||||
<ul>
|
||||
<li>✅ <strong>Fixed Width:</strong> All task status badges now 130px wide</li>
|
||||
<li>✅ <strong>Perfect Alignment:</strong> Columns line up properly</li>
|
||||
<li>✅ <strong>Professional Appearance:</strong> No layout shifts when status changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>📝 Documentation Updated</h2>
|
||||
<ul>
|
||||
<li><strong>Requirements.md:</strong> Updated Requirement 20 with thumbnail functionality and column control details</li>
|
||||
<li><strong>Design.md:</strong> Enhanced Asset Table design section with thumbnail and column visibility specifications</li>
|
||||
<li><strong>Tasks.md:</strong> Updated Task 12.6 title to include thumbnails</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🎯 Components Created/Modified</h2>
|
||||
<div class="checklist">
|
||||
<h4>New Components:</h4>
|
||||
<ul>
|
||||
<li><span class="code">ThumbnailToggle.vue</span> - Toggle switch for thumbnail column visibility</li>
|
||||
</ul>
|
||||
|
||||
<h4>Modified Components:</h4>
|
||||
<ul>
|
||||
<li><span class="code">ColumnVisibilityControl.vue</span> - Added thumbnail option, fixed functionality</li>
|
||||
<li><span class="code">AssetBrowser.vue</span> - Fixed imports, added thumbnail functionality, removed task count</li>
|
||||
<li><span class="code">EditableTaskStatus.vue</span> - Consistent 130px width for badges</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🧪 Testing Instructions</h2>
|
||||
<ol>
|
||||
<li><strong>Open Application:</strong> <span class="highlight">http://localhost:5174</span></li>
|
||||
<li><strong>Login:</strong> <span class="highlight">admin@vfx.com / admin123</span></li>
|
||||
<li><strong>Navigate:</strong> Project → Assets tab → List view</li>
|
||||
<li><strong>Test Column Control:</strong>
|
||||
<ul>
|
||||
<li>Click "Columns" dropdown - should open properly now</li>
|
||||
<li>Toggle different columns on/off</li>
|
||||
<li>Verify table updates immediately</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Test Thumbnail Toggle:</strong>
|
||||
<ul>
|
||||
<li>Use "Show Thumbnails" checkbox</li>
|
||||
<li>Verify thumbnail column appears/disappears</li>
|
||||
<li>Check sync with column dropdown</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Verify Badge Consistency:</strong>
|
||||
<ul>
|
||||
<li>All task status badges should be same width</li>
|
||||
<li>Perfect column alignment</li>
|
||||
<li>No task count column visible</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>🎉 Summary</h2>
|
||||
<p>All requested features have been successfully implemented:</p>
|
||||
<ul>
|
||||
<li>✅ <strong>Fixed Columns Dropdown:</strong> Now working properly with comprehensive controls</li>
|
||||
<li>✅ <strong>Consistent Badge Width:</strong> 130px width for perfect alignment</li>
|
||||
<li>✅ <strong>Thumbnail Column:</strong> Added with toggle control and session persistence</li>
|
||||
<li>✅ <strong>Removed Task Count:</strong> Cleaner focus on individual task status</li>
|
||||
<li>✅ <strong>Updated Documentation:</strong> Requirements, design, and tasks all updated</li>
|
||||
<li>✅ <strong>Build Success:</strong> No errors, ready for production</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="status" class="step">
|
||||
<h2>🔍 System Status</h2>
|
||||
<p id="status-text">Checking system readiness...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function checkStatus() {
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/health');
|
||||
if (response.ok) {
|
||||
statusText.innerHTML = '<span class="success">✅ System Ready - All enhancements available for testing!</span>';
|
||||
} else {
|
||||
statusText.innerHTML = '<span class="error">❌ Backend issue: ' + response.status + '</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
statusText.innerHTML = '<span class="error">❌ Backend not running. Start with: cd backend && python main.py</span>';
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+137
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.294.0",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"pinia": "^2.1.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"shadcn-vue": "^2.3.2",
|
||||
@@ -2789,6 +2790,60 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4053,6 +4108,15 @@
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"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_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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
@@ -7595,12 +7674,70 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.294.0",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"pinia": "^2.1.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"shadcn-vue": "^2.3.2",
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Frontend Response Format Validation Report
|
||||
|
||||
## Task 11: Frontend Response Format Validation
|
||||
|
||||
**Status:** ✅ COMPLETED
|
||||
**Date:** December 31, 2025
|
||||
|
||||
## Overview
|
||||
|
||||
This report validates that all optimized endpoints return embedded `task_statuses` field and that frontend components can consume the optimized data format, fulfilling Requirements 4.1, 4.2, and 4.3.
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
### ✅ Requirement 4.1: Shot API Response Format
|
||||
**VERIFIED:** Shot endpoints return embedded task_statuses field
|
||||
|
||||
**Evidence from `backend/routers/shots.py`:**
|
||||
```python
|
||||
# Lines 295-310: Shot response building
|
||||
shot_response = ShotListResponse.model_validate(shot)
|
||||
shot_response.task_count = len(shot_data['tasks'])
|
||||
shot_response.task_status = shot_data['task_status'] # ✅ task_status field
|
||||
shot_response.task_details = shot_data['task_details'] # ✅ task_details field
|
||||
```
|
||||
|
||||
**Schema Validation from `backend/schemas/shot.py`:**
|
||||
```python
|
||||
class ShotListResponse(BaseModel):
|
||||
# ... other fields ...
|
||||
task_status: Dict[str, Optional[TaskStatus]] = Field(default_factory=dict, description="Task status by task type")
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
```
|
||||
|
||||
### ✅ Requirement 4.2: Asset API Response Format
|
||||
**VERIFIED:** Asset endpoints return embedded task_statuses field
|
||||
|
||||
**Evidence from `backend/routers/assets.py`:**
|
||||
```python
|
||||
# Lines 295-310: Asset response building
|
||||
asset_response = AssetListResponse.model_validate(asset)
|
||||
asset_response.task_count = len(asset_data['tasks'])
|
||||
asset_response.task_status = asset_data['task_status'] # ✅ task_status field
|
||||
asset_response.task_details = asset_data['task_details'] # ✅ task_details field
|
||||
```
|
||||
|
||||
**Schema Validation from `backend/schemas/asset.py`:**
|
||||
```python
|
||||
class AssetListResponse(BaseModel):
|
||||
# ... other fields ...
|
||||
task_status: Dict[str, Optional[TaskStatus]] = Field(default_factory=dict, description="Task status by task type")
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
```
|
||||
|
||||
### ✅ Requirement 4.3: Complete Task Status Information
|
||||
**VERIFIED:** Task status data includes all required fields
|
||||
|
||||
**Evidence from `TaskStatusInfo` schema:**
|
||||
```python
|
||||
class TaskStatusInfo(BaseModel):
|
||||
task_type: str # ✅ Task type included
|
||||
status: str # ✅ Current status included
|
||||
task_id: Optional[int] # ✅ Task ID included
|
||||
assigned_user_id: Optional[int] # ✅ Assignee included
|
||||
```
|
||||
|
||||
**Backend Implementation:**
|
||||
```python
|
||||
# Lines 285-290: Task details population
|
||||
shots_dict[shot.id]['task_details'].append(TaskStatusInfo(
|
||||
task_type=task_type, # ✅ Task type
|
||||
status=task_status, # ✅ Current status
|
||||
task_id=task_id, # ✅ Task ID
|
||||
assigned_user_id=assigned_user_id # ✅ Assignee
|
||||
))
|
||||
```
|
||||
|
||||
## Frontend Component Compatibility
|
||||
|
||||
### ✅ ShotDetailPanel Component
|
||||
**VERIFIED:** Component uses embedded task data without additional API calls
|
||||
|
||||
**Evidence from `frontend/src/components/shot/ShotDetailPanel.vue`:**
|
||||
```typescript
|
||||
// Lines 180-190: Optimized task loading
|
||||
const loadTasks = () => {
|
||||
// Use task_details already embedded in shot data - no API call needed!
|
||||
if (shot.value?.task_details) {
|
||||
tasks.value = shot.value.task_details.map(taskInfo => ({
|
||||
id: taskInfo.task_id || 0,
|
||||
task_type: taskInfo.task_type,
|
||||
status: taskInfo.status,
|
||||
assigned_user_id: taskInfo.assigned_user_id,
|
||||
// ... other fields
|
||||
}))
|
||||
} else {
|
||||
tasks.value = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ AssetDetailPanel Component
|
||||
**VERIFIED:** Component uses embedded task data from asset response
|
||||
|
||||
**Evidence from `frontend/src/components/asset/AssetDetailPanel.vue`:**
|
||||
```typescript
|
||||
// Lines 85-100: Task data extraction from embedded asset data
|
||||
const tasks = computed(() => {
|
||||
if (!asset.value?.task_details) return []
|
||||
|
||||
return asset.value.task_details.map((taskInfo: TaskStatusInfo) => {
|
||||
return {
|
||||
id: taskInfo.task_id || 0,
|
||||
name: formatTaskType(taskInfo.task_type),
|
||||
task_type: taskInfo.task_type,
|
||||
status: taskInfo.status,
|
||||
assigned_user_id: taskInfo.assigned_user_id
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### ✅ TaskBrowser Component
|
||||
**VERIFIED:** Component extracts tasks from embedded shot and asset data
|
||||
|
||||
**Evidence from `frontend/src/components/task/TaskBrowser.vue`:**
|
||||
```typescript
|
||||
// Lines 200-240: Optimized task fetching
|
||||
const fetchTasks = async () => {
|
||||
// Get both shots and assets with embedded task data (two optimized backend calls)
|
||||
const [shots, assets] = await Promise.all([
|
||||
shotService.getShots({ projectId: props.projectId }),
|
||||
assetService.getAssets(props.projectId)
|
||||
])
|
||||
|
||||
// Extract tasks from embedded data - no separate task API calls needed!
|
||||
const shotTasks = shots.flatMap(shot =>
|
||||
(shot.task_details || []).map(taskDetail => ({ /* ... */ }))
|
||||
)
|
||||
const assetTasks = assets.flatMap(asset =>
|
||||
(asset.task_details || []).map(taskDetail => ({ /* ... */ }))
|
||||
)
|
||||
|
||||
tasks.value = [...shotTasks, ...assetTasks]
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ TasksStore
|
||||
**VERIFIED:** Store uses embedded data from shots and assets
|
||||
|
||||
**Evidence from `frontend/src/stores/tasks.ts`:**
|
||||
```typescript
|
||||
// Lines 40-70: Optimized task fetching
|
||||
async function fetchTasks(filters?: { projectId?: number }) {
|
||||
if (filters?.projectId) {
|
||||
// Use optimized approach: get both shots and assets with embedded task data
|
||||
const [shots, assets] = await Promise.all([
|
||||
shotService.getShotsByProject(filters.projectId),
|
||||
assetService.getAssets(filters.projectId)
|
||||
])
|
||||
|
||||
// Extract tasks from embedded data in shots and assets
|
||||
const shotTasks = extractTasksFromShots(shots)
|
||||
const assetTasks = extractTasksFromAssets(assets)
|
||||
|
||||
// Combine all tasks
|
||||
tasks.value = [...shotTasks, ...assetTasks]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Optimization Verification
|
||||
|
||||
### ✅ Single Query Operations
|
||||
**VERIFIED:** Both shot and asset endpoints use optimized JOIN queries
|
||||
|
||||
**Shot Endpoint Evidence:**
|
||||
```python
|
||||
# Lines 220-235: Single query with JOIN
|
||||
shots_with_tasks = (
|
||||
base_query
|
||||
.outerjoin(Task, (Task.shot_id == Shot.id) & (Task.deleted_at.is_(None)))
|
||||
.options(joinedload(Shot.episode).joinedload(Episode.project))
|
||||
.add_columns(
|
||||
Task.id.label('task_id'),
|
||||
Task.task_type,
|
||||
Task.status.label('task_status'),
|
||||
Task.assigned_user_id
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
**Asset Endpoint Evidence:**
|
||||
```python
|
||||
# Lines 220-235: Single query with JOIN
|
||||
assets_with_tasks = (
|
||||
base_query
|
||||
.outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None)))
|
||||
.options(joinedload(Asset.project))
|
||||
.add_columns(
|
||||
Task.id.label('task_id'),
|
||||
Task.task_type,
|
||||
Task.status.label('task_status'),
|
||||
Task.assigned_user_id
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Benefits Achieved
|
||||
|
||||
### 🚀 API Call Reduction
|
||||
- **Before:** N+1 queries (1 for shots/assets + N for individual task queries)
|
||||
- **After:** Single JOIN query per endpoint
|
||||
- **Frontend:** Components use embedded data instead of separate task API calls
|
||||
|
||||
### 🚀 Network Request Optimization
|
||||
- **ShotDetailPanel:** Eliminated redundant `taskService.getTasks({ shotId })` calls
|
||||
- **TaskBrowser:** Reduced from 3 API calls to 2 (shots + assets, no separate tasks call)
|
||||
- **TasksStore:** Uses embedded data extraction instead of separate task queries
|
||||
|
||||
### 🚀 Data Consistency
|
||||
- Task status and details are fetched atomically with parent entities
|
||||
- No risk of stale data between separate API calls
|
||||
- Real-time consistency between shots/assets and their tasks
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Manual Testing Recommendations
|
||||
1. **API Response Validation:**
|
||||
```bash
|
||||
# Test shot endpoint
|
||||
curl "http://localhost:8000/api/shots/?project_id=1" | jq '.[] | {id, name, task_status, task_details}'
|
||||
|
||||
# Test asset endpoint
|
||||
curl "http://localhost:8000/api/assets/?project_id=1" | jq '.[] | {id, name, task_status, task_details}'
|
||||
```
|
||||
|
||||
2. **Frontend Component Testing:**
|
||||
- Open ShotDetailPanel and verify tasks load without additional network requests
|
||||
- Open TaskBrowser and verify task data is extracted from embedded shot/asset data
|
||||
- Check browser Network tab to confirm reduced API calls
|
||||
|
||||
3. **Performance Testing:**
|
||||
- Compare page load times before/after optimization
|
||||
- Monitor database query count in backend logs
|
||||
- Verify sub-500ms response times for 100+ shots/assets
|
||||
|
||||
## Conclusion
|
||||
|
||||
✅ **TASK 11 COMPLETED SUCCESSFULLY**
|
||||
|
||||
All requirements have been verified through code analysis:
|
||||
|
||||
1. **✅ Requirement 4.1:** Shot endpoints return embedded `task_status` field with all associated task information
|
||||
2. **✅ Requirement 4.2:** Asset endpoints return embedded `task_status` field with all associated task information
|
||||
3. **✅ Requirement 4.3:** Task status data includes task type, current status, assignee, and task ID information
|
||||
|
||||
**Frontend components successfully consume the optimized data format:**
|
||||
- ShotDetailPanel uses embedded task data
|
||||
- AssetDetailPanel uses embedded task data
|
||||
- TaskBrowser extracts tasks from embedded data
|
||||
- TasksStore leverages optimized data fetching
|
||||
|
||||
**Performance optimizations achieved:**
|
||||
- Single JOIN queries replace N+1 patterns
|
||||
- Frontend components eliminate redundant API calls
|
||||
- Network requests reduced significantly
|
||||
- Data consistency improved through atomic fetching
|
||||
|
||||
The response format validation is complete and all optimized endpoints are functioning as designed.
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<ActivityIcon class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No activity yet</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Activity Timeline</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="loadActivities"
|
||||
:disabled="loading"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
Loading timeline...
|
||||
</div>
|
||||
|
||||
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Clock class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No activity recorded</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
<!-- Timeline items -->
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="relative pl-10"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-2.5 w-3 h-3 rounded-full border-2 border-background"
|
||||
:class="getTimelineDotColor(activity.type)"
|
||||
/>
|
||||
|
||||
<!-- Activity content -->
|
||||
<div class="bg-card border rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="getActivityIcon(activity.type)"
|
||||
class="h-5 w-5 mt-0.5 flex-shrink-0"
|
||||
:class="getActivityColor(activity.type)"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{{ getActivityTitle(activity.type) }}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{{ activity.description }}</p>
|
||||
|
||||
<!-- Metadata display -->
|
||||
<div v-if="activity.activity_metadata" class="mt-2 text-xs text-muted-foreground">
|
||||
<div v-if="activity.activity_metadata.old_status && activity.activity_metadata.new_status">
|
||||
<Badge variant="outline" class="mr-2">{{ activity.activity_metadata.old_status }}</Badge>
|
||||
→
|
||||
<Badge variant="outline" class="ml-2">{{ activity.activity_metadata.new_status }}</Badge>
|
||||
</div>
|
||||
<div v-if="activity.activity_metadata.version">
|
||||
Version {{ activity.activity_metadata.version }}
|
||||
</div>
|
||||
<div v-if="activity.activity_metadata.decision">
|
||||
Decision: <Badge :variant="activity.activity_metadata.decision === 'approved' ? 'default' : 'destructive'">
|
||||
{{ activity.activity_metadata.decision }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Avatar class="h-5 w-5">
|
||||
<AvatarFallback class="text-xs">
|
||||
{{ getInitials(activity.user) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ activity.user.first_name }} {{ activity.user.last_name }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">•</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
RefreshCw
|
||||
} from 'lucide-vue-next'
|
||||
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
|
||||
import * as activityService from '@/services/activity'
|
||||
|
||||
interface Props {
|
||||
taskId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const activities = ref<Activity[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
watch(() => props.taskId, () => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
async function loadActivities() {
|
||||
loading.value = true
|
||||
try {
|
||||
activities.value = await activityService.getTaskActivities(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task activities:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(user: UserInfo): string {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase()
|
||||
}
|
||||
|
||||
function getActivityIcon(type: ActivityType) {
|
||||
const iconMap: Record<string, any> = {
|
||||
task_created: FileText,
|
||||
task_updated: FileText,
|
||||
task_assigned: UserPlus,
|
||||
task_status_changed: CheckCircle,
|
||||
submission_created: FileText,
|
||||
submission_reviewed: CheckCircle,
|
||||
comment_added: MessageSquare
|
||||
}
|
||||
return iconMap[type] || FileText
|
||||
}
|
||||
|
||||
function getActivityColor(type: ActivityType): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
task_created: 'text-blue-500',
|
||||
task_updated: 'text-blue-500',
|
||||
task_assigned: 'text-purple-500',
|
||||
task_status_changed: 'text-green-500',
|
||||
submission_created: 'text-orange-500',
|
||||
submission_reviewed: 'text-green-500',
|
||||
comment_added: 'text-gray-500'
|
||||
}
|
||||
return colorMap[type] || 'text-gray-500'
|
||||
}
|
||||
|
||||
function getTimelineDotColor(type: ActivityType): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
task_created: 'bg-blue-500',
|
||||
task_updated: 'bg-blue-500',
|
||||
task_assigned: 'bg-purple-500',
|
||||
task_status_changed: 'bg-green-500',
|
||||
submission_created: 'bg-orange-500',
|
||||
submission_reviewed: 'bg-green-500',
|
||||
comment_added: 'bg-gray-500'
|
||||
}
|
||||
return colorMap[type] || 'bg-gray-500'
|
||||
}
|
||||
|
||||
function getActivityTitle(type: ActivityType): string {
|
||||
const titleMap: Record<string, string> = {
|
||||
task_created: 'Task Created',
|
||||
task_updated: 'Task Updated',
|
||||
task_assigned: 'Task Assigned',
|
||||
task_status_changed: 'Status Changed',
|
||||
submission_created: 'Work Submitted',
|
||||
submission_reviewed: 'Submission Reviewed',
|
||||
comment_added: 'Comment Added'
|
||||
}
|
||||
return titleMap[type] || 'Activity'
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<!-- Main Content (Full Width) -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="relative h-full flex flex-col">
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<AssetTableToolbar
|
||||
v-if="customTaskTypesLoaded"
|
||||
:view-mode="viewMode"
|
||||
@@ -16,6 +16,7 @@
|
||||
:selected-asset="selectedAsset"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:is-columns-locked="lockColumns"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:category-filter="handleCategoryFilterChange"
|
||||
@update:search="searchQuery = $event"
|
||||
@@ -23,6 +24,7 @@
|
||||
@update:show-thumbnails="handleThumbnailToggle"
|
||||
@task-status-filter-changed="handleTaskStatusFilter"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
@toggle-column-lock="toggleColumnLock"
|
||||
@create-asset="showCreateDialog = true"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center py-4">
|
||||
@@ -34,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
|
||||
@@ -44,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
@@ -59,7 +61,7 @@
|
||||
v-else-if="
|
||||
filteredAssets.length === 0 && !searchQuery && !selectedCategory
|
||||
"
|
||||
class="text-center py-12"
|
||||
class="text-center py-12 px-4 sm:px-6"
|
||||
>
|
||||
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
|
||||
@@ -73,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No Results State -->
|
||||
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
|
||||
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
|
||||
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
@@ -83,11 +85,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid/List -->
|
||||
<div v-else>
|
||||
<div v-else class="flex-1 min-h-0">
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
@@ -109,12 +111,13 @@
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
:lock-columns="lockColumns"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center py-12">
|
||||
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading table...</span>
|
||||
@@ -161,73 +164,39 @@
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
|
||||
action cannot be undone and will remove all associated tasks.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@click="handleDeleteAsset"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete Asset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AssetDeleteConfirmDialog
|
||||
v-if="deletionInfo && assetToDelete"
|
||||
:open="showDeleteDialog"
|
||||
:asset-id="assetToDelete.id"
|
||||
:asset-name="deletionInfo.asset_name"
|
||||
@update:open="showDeleteDialog = $event"
|
||||
@confirm-delete="handleDeleteAsset"
|
||||
/>
|
||||
</div>
|
||||
<!-- Asset Detail Panel (Desktop) with slide animation -->
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
<!-- Asset Detail Panel (Desktop + Mobile) -->
|
||||
<DetailPanelOverlay
|
||||
:visible="!!(showPanel && selectedAsset)"
|
||||
v-model:mobile-open="showMobileDetail"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadAssets"
|
||||
/>
|
||||
<AssetDetailPanel
|
||||
v-else-if="selectedAsset"
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
:all-task-types="allTaskTypes"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Asset Detail Panel (Mobile) -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<AssetDetailPanel
|
||||
v-if="selectedAsset"
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</DetailPanelOverlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -251,20 +220,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
|
||||
import AssetCard from "./AssetCard.vue";
|
||||
import AssetForm from "./AssetForm.vue";
|
||||
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
||||
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
|
||||
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
|
||||
import AssetsDataTable from "./AssetsDataTable.vue";
|
||||
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
||||
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
||||
@@ -273,11 +234,13 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { useTaskStatusesStore } from "@/stores/taskStatuses";
|
||||
import { useDetailPanel } from "@/composables/useDetailPanel";
|
||||
import {
|
||||
assetService,
|
||||
AssetCategory,
|
||||
TaskStatus,
|
||||
type Asset,
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
type AssetDeletionInfo,
|
||||
} from "@/services/asset";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
|
||||
@@ -385,6 +348,9 @@ const showDeleteDialog = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const assetToDelete = ref<Asset | null>(null);
|
||||
const deletionInfo = ref<AssetDeletionInfo | null>(null);
|
||||
|
||||
const taskStatusFilter = ref('')
|
||||
|
||||
// Thumbnail display state - with session storage
|
||||
@@ -392,6 +358,13 @@ const showThumbnails = ref(
|
||||
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
|
||||
);
|
||||
|
||||
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
|
||||
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
|
||||
const toggleColumnLock = () => {
|
||||
lockColumns.value = !lockColumns.value;
|
||||
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const assets = computed(() => assetsStore.assets);
|
||||
const isLoading = computed(() => assetsStore.isLoading);
|
||||
@@ -451,6 +424,7 @@ const assetColumns = computed(() => {
|
||||
onDelete: deleteAsset,
|
||||
onViewTasks: viewAssetTasks,
|
||||
onTaskStatusUpdated: handleTaskStatusUpdate,
|
||||
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||
getSelectedCount: () => selectedCount.value,
|
||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
||||
@@ -563,14 +537,25 @@ const editAsset = (asset: Asset) => {
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteAsset = (asset: Asset) => {
|
||||
selectedAsset.value = asset;
|
||||
const deleteAsset = async (asset: Asset) => {
|
||||
// Don't set selectedAsset here as it opens the detail panel
|
||||
assetToDelete.value = asset;
|
||||
|
||||
try {
|
||||
deletionInfo.value = await assetService.getAssetDeletionInfo(asset.id);
|
||||
showDeleteDialog.value = true;
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to get asset information",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const viewAssetTasks = (asset: Asset) => {
|
||||
// TODO: Navigate to asset tasks view
|
||||
console.log("View tasks for asset:", asset.name);
|
||||
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
|
||||
selectAsset(asset);
|
||||
};
|
||||
|
||||
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
@@ -619,15 +604,21 @@ const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
if (!selectedAsset.value) return;
|
||||
if (!assetToDelete.value) return;
|
||||
|
||||
try {
|
||||
await assetsStore.deleteAsset(selectedAsset.value.id);
|
||||
await assetsStore.deleteAsset(assetToDelete.value.id);
|
||||
|
||||
showDeleteDialog.value = false;
|
||||
selectedAsset.value = null;
|
||||
assetToDelete.value = null;
|
||||
|
||||
const taskCount = deletionInfo.value?.task_count || 0;
|
||||
deletionInfo.value = null;
|
||||
toast({
|
||||
title: "Asset deleted",
|
||||
description: "Asset has been deleted successfully.",
|
||||
description: taskCount > 0
|
||||
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
|
||||
: "Asset has been deleted successfully.",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -660,6 +651,24 @@ const handleTaskStatusUpdate = async (
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
|
||||
// Update local state instead of reloading all assets
|
||||
const asset = assetsStore.assets.find((a) => a.id === assetId);
|
||||
if (asset && asset.task_details) {
|
||||
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
|
||||
if (taskDetail) {
|
||||
taskDetail.assigned_user_id = userId ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Task assignment updated",
|
||||
description: userId
|
||||
? `${formatTaskType(taskType)} task assigned successfully.`
|
||||
: `${formatTaskType(taskType)} task unassigned successfully.`,
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = "all";
|
||||
searchQuery.value = "";
|
||||
@@ -677,30 +686,19 @@ const formatTaskType = (taskType: string) => {
|
||||
};
|
||||
|
||||
// Detail panel event handlers
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this asset
|
||||
console.log('Create task for asset:', selectedAsset.value?.name);
|
||||
const selectedTaskId = ref<number | null>(null);
|
||||
const selectedTaskTab = ref<string>('infos');
|
||||
|
||||
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||
selectedTaskId.value = task.id;
|
||||
selectedTaskTab.value = tab || 'infos';
|
||||
};
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Open task detail panel
|
||||
console.log('Select task:', task);
|
||||
};
|
||||
|
||||
const handleCreateNote = () => {
|
||||
// TODO: Open note creation dialog
|
||||
console.log('Create note for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handleUploadReference = () => {
|
||||
// TODO: Open reference upload dialog
|
||||
console.log('Upload reference for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handlePublishVersion = () => {
|
||||
// TODO: Open version publish dialog
|
||||
console.log('Publish version for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
// Reset the task sub-panel whenever the asset selection changes (including close)
|
||||
watch(selectedAsset, () => {
|
||||
selectedTaskId.value = null;
|
||||
selectedTaskTab.value = 'infos';
|
||||
});
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="font-medium text-destructive">Failed to load deletion information</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
|
||||
</div>
|
||||
<Alert v-else-if="loadError" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertTitle>Failed to load deletion information</AlertTitle>
|
||||
<AlertDescription>{{ loadError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Deletion Information -->
|
||||
<div v-else-if="deletionInfo" class="space-y-4">
|
||||
@@ -73,15 +71,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Affected Users -->
|
||||
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
|
||||
<Users class="h-4 w-4 text-orange-600" />
|
||||
<span class="font-medium text-orange-800">
|
||||
<AlertTitle class="text-orange-800">
|
||||
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-orange-700 mb-3">
|
||||
</AlertTitle>
|
||||
<AlertDescription class="text-orange-700">
|
||||
<p class="mb-3">
|
||||
The following users have work associated with this asset that will be marked as deleted:
|
||||
</p>
|
||||
|
||||
@@ -93,7 +89,7 @@
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} • {{ user.role }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||
@@ -105,27 +101,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- No affected users -->
|
||||
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Alert v-else variant="default" class="border-green-200 bg-green-50">
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<span class="text-sm text-green-800">No users will be affected by this deletion.</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDescription class="text-green-800">
|
||||
No users will be affected by this deletion.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Data Preservation Notice -->
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Alert variant="default" class="border-blue-200 bg-blue-50">
|
||||
<Shield class="h-4 w-4 text-blue-600" />
|
||||
<span class="font-medium text-blue-800">Data Preservation</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700">
|
||||
<AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
|
||||
<AlertDescription class="text-blue-700">
|
||||
All data will be preserved in the database and can be recovered by administrators.
|
||||
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Confirmation input -->
|
||||
<div class="space-y-2">
|
||||
@@ -189,6 +184,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/ui/alert'
|
||||
import { assetService, type AssetDeletionInfo } from '@/services/asset'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
@@ -275,7 +275,7 @@ watch(() => props.open, (newOpen) => {
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
@@ -1,64 +1,48 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading asset details...</span>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-6 text-center">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadAssetDetails" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<DetailPanelError
|
||||
v-else-if="error"
|
||||
title="Failed to load asset"
|
||||
:message="error"
|
||||
@retry="loadAssetDetails"
|
||||
/>
|
||||
|
||||
<!-- Asset Details -->
|
||||
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': asset.deleted_at }">{{ asset.name }}</h2>
|
||||
<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>
|
||||
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
|
||||
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||
<template #badges>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DetailPanelHeader>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
<TabsTrigger value="notes">
|
||||
Notes
|
||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
||||
{{ notes.length }}
|
||||
</Badge>
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||
<TabsTrigger value="infos" title="Infos">
|
||||
<Info class="h-4 w-4" />
|
||||
<span class="sr-only">Infos</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="references">
|
||||
References
|
||||
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
|
||||
<TabsTrigger value="notes" title="Notes">
|
||||
<span class="relative inline-flex">
|
||||
<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 }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
@@ -117,7 +101,110 @@
|
||||
|
||||
<!-- Task Status & Assignees -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Add Task -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
|
||||
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
All task types already added
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="type in availableTaskTypes"
|
||||
:key="type"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
:disabled="isCreatingTask"
|
||||
@click="handleAddTask(type)"
|
||||
>
|
||||
{{ formatTaskType(type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Add Note (picks a task, opens its Notes tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
|
||||
<MessageSquarePlus class="h-4 w-4" />
|
||||
</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')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference 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, 'attachments')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Publish Version (picks a task, opens its Submissions tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for 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, 'submissions')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Tasks -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||
@@ -168,7 +255,13 @@
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
|
||||
<EntityNotes
|
||||
:key="assetId"
|
||||
:tasks="tasks"
|
||||
:notes="notes"
|
||||
:submissions="submissions"
|
||||
@notes-updated="loadNotes"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- References Tab -->
|
||||
@@ -183,18 +276,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, X
|
||||
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import AssetNotes from './AssetNotes.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||
import AssetReferences from './AssetReferences.vue'
|
||||
|
||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
|
||||
interface Task {
|
||||
id: number
|
||||
@@ -208,16 +307,13 @@ interface Task {
|
||||
interface Props {
|
||||
projectId: number
|
||||
assetId: number
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', asset: Asset): void
|
||||
(e: 'delete', asset: Asset): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'publish-version'): void
|
||||
(e: 'select-task', task: Task, tab?: string): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
@@ -226,13 +322,16 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
// Reactive state
|
||||
const asset = ref<Asset | null>(null)
|
||||
const notes = ref<any[]>([])
|
||||
const notes = ref<ProductionNote[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const references = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const tasks = computed(() => {
|
||||
@@ -266,6 +365,17 @@ const progressPercentage = computed(() => {
|
||||
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
||||
})
|
||||
|
||||
// Task types offered in "Add Task" include the flat asset task type list
|
||||
// plus every task type owned by an asset department (e.g. Modeling's own
|
||||
// task types), deduped against types the asset already has a task for.
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
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 counts = {
|
||||
not_started: 0,
|
||||
@@ -292,6 +402,7 @@ const loadAssetDetails = async () => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
asset.value = await assetService.getAsset(props.assetId)
|
||||
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||
|
||||
// Load users if not already loaded (for user name resolution)
|
||||
if (userStore.users.length === 0) {
|
||||
@@ -310,28 +421,18 @@ const loadAssetDetails = async () => {
|
||||
}
|
||||
|
||||
const loadNotes = async () => {
|
||||
if (tasks.value.length === 0) {
|
||||
notes.value = []
|
||||
submissions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Load notes from all tasks associated with this asset
|
||||
const { taskService } = await import('@/services/task')
|
||||
const allNotes: any[] = []
|
||||
|
||||
for (const task of tasks.value) {
|
||||
if (task.id) {
|
||||
const taskNotes = await taskService.getTaskNotes(task.id)
|
||||
// Add task info to each note for context
|
||||
const notesWithContext = taskNotes.map(note => ({
|
||||
...note,
|
||||
task_name: task.name,
|
||||
task_type: task.task_type
|
||||
}))
|
||||
allNotes.push(...notesWithContext)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
notes.value = allNotes.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
const [notesByTask, submissionsByTask] = await Promise.all([
|
||||
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
||||
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
||||
])
|
||||
notes.value = notesByTask.flat()
|
||||
submissions.value = submissionsByTask.flat()
|
||||
} catch (err) {
|
||||
console.error('Failed to load notes:', err)
|
||||
}
|
||||
@@ -365,6 +466,18 @@ const loadReferences = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTask = async (taskType: string) => {
|
||||
isCreatingTask.value = true
|
||||
try {
|
||||
await taskService.createAssetTask(props.assetId, taskType)
|
||||
await loadAssetDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
isCreatingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatCategory = (category: string) => {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -43,8 +43,8 @@
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0 flex-shrink-0"
|
||||
size="icon-sm"
|
||||
class="flex-shrink-0"
|
||||
@click="downloadFile(reference)"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
|
||||
@@ -5,26 +5,11 @@
|
||||
<!-- Left Side - Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'grid' }"
|
||||
@click="$emit('update:view-mode', 'grid')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<LayoutGrid class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'list' }"
|
||||
@click="$emit('update:view-mode', 'list')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedToggle
|
||||
:options="viewModeOptions"
|
||||
:model-value="viewMode"
|
||||
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list')"
|
||||
/>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Popover>
|
||||
@@ -47,139 +32,44 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
value="all"
|
||||
@select="$emit('update:category-filter', 'all')"
|
||||
:model-value="categoryFilter === 'all'"
|
||||
@update:model-value="$emit('update:category-filter', 'all')"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
categoryFilter === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Categories</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
</CheckableCommandItem>
|
||||
<CheckableCommandItem
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
:value="category.value"
|
||||
@select="$emit('update:category-filter', category.value)"
|
||||
:model-value="categoryFilter === category.value"
|
||||
@update:model-value="$emit('update:category-filter', category.value)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
categoryFilter === category.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<component :is="category.icon" class="h-4 w-4 mr-2" />
|
||||
<span>{{ category.label }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Thumbnail Toggle Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$emit('update:show-thumbnails', !showThumbnails)"
|
||||
class="h-8"
|
||||
>
|
||||
<ImageIcon v-if="!showThumbnails" class="h-4 w-4 mr-2" />
|
||||
<ImageOff v-else class="h-4 w-4 mr-2" />
|
||||
{{ showThumbnails ? 'Hide' : 'Show' }} Thumbnails
|
||||
</Button>
|
||||
|
||||
<!-- Task Status Filter (only for list view) -->
|
||||
<TaskStatusFilter
|
||||
v-if="viewMode === 'list'"
|
||||
:all-task-types="allTaskTypes"
|
||||
:project-id="projectId"
|
||||
@filter-changed="$emit('task-status-filter-changed', $event)"
|
||||
/>
|
||||
|
||||
<!-- Column Visibility Control (only for list view) -->
|
||||
<Popover v-if="viewMode === 'list'">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'default'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Task Types
|
||||
</div>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'task'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColumnToggleList
|
||||
v-if="viewMode === 'list'"
|
||||
:columns="columnToggleOptions"
|
||||
:model-value="columnVisibility"
|
||||
@update:model-value="$emit('update:column-visibility', $event)"
|
||||
/>
|
||||
|
||||
<!-- Task Columns Toggle Button (only for list view) -->
|
||||
<Button
|
||||
@@ -195,32 +85,27 @@
|
||||
</Button>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
|
||||
<DetailPanelToggleButton
|
||||
v-if="viewMode === 'list'"
|
||||
:enabled="isDetailPanelEnabled"
|
||||
@toggle="$emit('toggle-detail-panel')"
|
||||
/>
|
||||
|
||||
<!-- Lock First Columns Toggle Button (only for list view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'list'"
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
@click="$emit('toggle-column-lock')"
|
||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||
size="icon-sm"
|
||||
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||
<Unlock v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Search and Actions -->
|
||||
@@ -236,8 +121,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail Toggle -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click="$emit('update:show-thumbnails', !showThumbnails)"
|
||||
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
|
||||
>
|
||||
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
|
||||
<ImageOff v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Create Asset Button -->
|
||||
<Button @click="$emit('create-asset')" size="sm" class="h-8 w-8 p-0">
|
||||
<Button @click="$emit('create-asset')" size="icon-sm" title="Create Asset">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -249,7 +145,8 @@
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
ListTodo, ListX,
|
||||
Lock, Unlock
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -264,11 +161,15 @@ import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CheckableCommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusFilter from './TaskStatusFilter.vue'
|
||||
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
|
||||
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
|
||||
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
|
||||
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
|
||||
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
|
||||
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Asset, AssetCategory } from '@/services/asset'
|
||||
|
||||
@@ -289,6 +190,7 @@ interface Props {
|
||||
selectedAsset: Asset | null
|
||||
isDetailPanelEnabled: boolean
|
||||
showThumbnails: boolean
|
||||
isColumnsLocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -301,6 +203,7 @@ const emit = defineEmits<{
|
||||
'update:show-thumbnails': [value: boolean]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'toggle-column-lock': []
|
||||
'create-asset': []
|
||||
}>()
|
||||
|
||||
@@ -327,9 +230,19 @@ const hasFilters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
|
||||
const columnToggleOptions = computed(() =>
|
||||
allColumns.value.map(col => ({
|
||||
id: col.id,
|
||||
label: col.label,
|
||||
group: col.type === 'task' ? 'Task Types' : undefined,
|
||||
}))
|
||||
)
|
||||
|
||||
const viewModeOptions = [
|
||||
{ value: 'grid', icon: LayoutGrid },
|
||||
{ value: 'list', icon: List },
|
||||
]
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
@@ -338,21 +251,9 @@ const allTaskColumnsVisible = computed(() => {
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||
|
||||
// Methods
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newVisibility = { ...props.columnVisibility }
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
|
||||
@@ -1,8 +1,106 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div class="px-4 h-full flex flex-col">
|
||||
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||
<template v-if="lockColumns">
|
||||
<div v-if="hasRows" class="flex h-full">
|
||||
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
|
||||
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in frozenHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:style="frozenWidth(header.column.id)"
|
||||
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in frozenCells(row)"
|
||||
:key="cell.id"
|
||||
:style="frozenWidth(cell.column.id)"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
|
||||
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in movableHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'actions' ? 'w-12' : '',
|
||||
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in movableCells(row)"
|
||||
:key="cell.id"
|
||||
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No results.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unlocked: standard single table -->
|
||||
<Table v-else>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
@@ -67,13 +165,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type HeaderGroup,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
@@ -93,10 +193,51 @@ interface Props {
|
||||
sorting: SortingState
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
lockColumns?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Frozen (locked) columns and their fixed widths (px), in column order.
|
||||
const FROZEN: Record<string, number> = {
|
||||
select: 48,
|
||||
thumbnail: 96,
|
||||
name: 200,
|
||||
}
|
||||
|
||||
const isFrozen = (id: string) => id in FROZEN
|
||||
|
||||
const frozenWidth = (id: string) => {
|
||||
const w = FROZEN[id]
|
||||
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
|
||||
}
|
||||
|
||||
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
|
||||
// respect column visibility; frozen ones are always shown.
|
||||
const frozenHeaders = (group: HeaderGroup<Asset>) =>
|
||||
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const movableHeaders = (group: HeaderGroup<Asset>) =>
|
||||
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const frozenCells = (row: Row<Asset>) =>
|
||||
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
|
||||
const movableCells = (row: Row<Asset>) =>
|
||||
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
|
||||
|
||||
const hasRows = computed(() => table.getRowModel().rows.length > 0)
|
||||
|
||||
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
|
||||
const leftPane = ref<HTMLElement>()
|
||||
const rightPane = ref<HTMLElement>()
|
||||
let syncing = false
|
||||
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
|
||||
if (syncing || !from || !to) return
|
||||
syncing = true
|
||||
to.scrollTop = from.scrollTop
|
||||
requestAnimationFrame(() => { syncing = false })
|
||||
}
|
||||
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
|
||||
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
@@ -277,4 +418,13 @@ watch(
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
</style>
|
||||
@@ -1,256 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Global Task Columns Toggle Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllTaskColumns"
|
||||
class="h-9"
|
||||
>
|
||||
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
|
||||
<ListX v-else class="h-4 w-4 mr-2" />
|
||||
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Column Visibility Dropdown -->
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.thumbnail"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('thumbnail', val)"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.name"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('name', val)"
|
||||
/>
|
||||
<span>Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="category" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.category"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('category', val)"
|
||||
/>
|
||||
<span>Category</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.status"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('status', val)"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<!-- Standard Task Types -->
|
||||
<SelectItem value="modeling" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.modeling"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('modeling', val)"
|
||||
/>
|
||||
<span>Modeling</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="surfacing" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.surfacing"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('surfacing', val)"
|
||||
/>
|
||||
<span>Surfacing</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="rigging" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.rigging"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('rigging', val)"
|
||||
/>
|
||||
<span>Rigging</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<!-- Custom Task Types -->
|
||||
<SelectItem
|
||||
v-for="customType in customTaskTypes"
|
||||
:key="customType"
|
||||
:value="customType"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns[customType]"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn(customType, val)"
|
||||
/>
|
||||
<span>{{ formatTaskType(customType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other Columns</SelectLabel>
|
||||
<SelectItem value="description" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.description"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('description', val)"
|
||||
/>
|
||||
<span>Description</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="updatedAt" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.updatedAt"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('updatedAt', val)"
|
||||
/>
|
||||
<span>Updated</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Columns, ListTodo, ListX } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface Props {
|
||||
visibleColumns: Record<string, boolean>;
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:visibleColumns", columns: Record<string, boolean>): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
const customTaskTypes = ref<string[]>([])
|
||||
const savedTaskColumnStates = ref<Record<string, boolean>>({})
|
||||
|
||||
// Standard task types
|
||||
const standardTaskTypes = ['modeling', 'surfacing', 'rigging']
|
||||
|
||||
// All task types (standard + custom)
|
||||
const allTaskTypes = computed(() => [...standardTaskTypes, ...customTaskTypes.value])
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
return allTaskTypes.value.every(taskType => props.visibleColumns[taskType])
|
||||
})
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const { projectService } = await import('@/services/project')
|
||||
const project = await projectService.getProject(props.projectId)
|
||||
customTaskTypes.value = project.custom_asset_task_types || []
|
||||
|
||||
// Initialize visibility for custom task types if not already set
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
let hasChanges = false
|
||||
|
||||
for (const customType of customTaskTypes.value) {
|
||||
if (!(customType in newColumns)) {
|
||||
newColumns[customType] = true // Show custom types by default
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load custom task types:', error)
|
||||
// Continue without custom task types
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle all task columns show/hide
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
|
||||
if (allTaskColumnsVisible.value) {
|
||||
// Hide all task columns but save their states
|
||||
savedTaskColumnStates.value = {}
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
savedTaskColumnStates.value[taskType] = newColumns[taskType]
|
||||
newColumns[taskType] = false
|
||||
}
|
||||
} else {
|
||||
// Restore saved states or show all
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
if (taskType in savedTaskColumnStates.value) {
|
||||
newColumns[taskType] = savedTaskColumnStates.value[taskType]
|
||||
} else {
|
||||
newColumns[taskType] = true
|
||||
}
|
||||
}
|
||||
savedTaskColumnStates.value = {}
|
||||
}
|
||||
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
|
||||
const handleColumnToggle = () => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean | 'indeterminate') => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
newColumns[column] = checked === true
|
||||
emit('update:visibleColumns', newColumns)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomTaskTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
>
|
||||
<div class="relative flex items-center gap-1">
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
@@ -12,12 +11,7 @@
|
||||
>
|
||||
<SelectValue
|
||||
:model-value="currentStatusId"
|
||||
>
|
||||
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
|
||||
<!-- console.log(currentStatusObject) -->
|
||||
|
||||
<!-- currentStatusObject -->
|
||||
</SelectValue>
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -38,6 +32,112 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- User Assignment Button -->
|
||||
<div @click.stop>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-accent relative"
|
||||
:disabled="isUpdating"
|
||||
@click.stop="ensureMembersLoaded"
|
||||
>
|
||||
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<User class="h-3 w-3" v-else />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
|
||||
<div class="space-y-2">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||
|
||||
<!-- Current Assignment Display with X button -->
|
||||
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground">Current</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
@click.stop="handleAssignUser(null)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
class="pl-7 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span class="ml-2 text-sm">Loading members...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No project members found
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
@click="loadProjectMembers"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content when members are loaded -->
|
||||
<template v-else>
|
||||
<!-- Project members list -->
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
|
||||
No matching members found
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
v-for="member in filteredProjectMembers"
|
||||
:key="member.user_id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start h-10"
|
||||
@click="handleAssignUser(member.user_id)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<Avatar class="h-8 w-8 mr-2">
|
||||
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||
</div>
|
||||
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
@@ -57,11 +157,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -76,19 +188,48 @@ interface Props {
|
||||
status: TaskStatus | string
|
||||
taskId?: number | null
|
||||
projectId: number
|
||||
assignedUserId?: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
|
||||
(e: 'assignment-updated', assetId: number, taskType: string, userId: number | null): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const projectMembersStore = useProjectMembersStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filtered project members based on search query
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// Get assigned user info
|
||||
const assignedUserId = computed(() => props.assignedUserId)
|
||||
const assignedUser = computed(() => {
|
||||
if (!assignedUserId.value) return null
|
||||
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
|
||||
})
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
@@ -144,6 +285,18 @@ const formatStatusName = (status: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// Format department role for display
|
||||
const formatDepartmentRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
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()
|
||||
}
|
||||
|
||||
// Fetch custom statuses for the project using store
|
||||
const fetchStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
@@ -155,6 +308,57 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members (shared cache across all EditableTaskStatus instances for this project)
|
||||
const loadProjectMembers = async () => {
|
||||
try {
|
||||
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure members are loaded when popover is about to open
|
||||
const ensureMembersLoaded = () => {
|
||||
if (projectMembers.value.length === 0) {
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId: number | null) => {
|
||||
isAssigning.value = true
|
||||
|
||||
try {
|
||||
let taskId = props.taskId
|
||||
|
||||
// If no task exists, create one first
|
||||
if (!taskId) {
|
||||
const newTask = await taskService.createAssetTask(props.assetId, props.taskType)
|
||||
taskId = newTask.task_id
|
||||
}
|
||||
|
||||
// Assign or unassign the task
|
||||
if (taskId) {
|
||||
if (userId) {
|
||||
// Use the assignment endpoint for assigning to a user
|
||||
await taskService.assignTask(taskId, userId)
|
||||
} else {
|
||||
// Use the update endpoint for unassignment (set assigned_user_id to 0)
|
||||
await taskService.updateTask(taskId, { assigned_user_id: 0 })
|
||||
}
|
||||
emit('assignment-updated', props.assetId, props.taskType, 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
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatusId: any) => {
|
||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||
|
||||
@@ -189,10 +393,13 @@ const handleStatusChange = async (newStatusId: any) => {
|
||||
// Fetch statuses on mount
|
||||
onMounted(() => {
|
||||
fetchStatuses()
|
||||
// Preload project members to ensure they're available when needed
|
||||
loadProjectMembers()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
// Refetch statuses and members when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
loadProjectMembers()
|
||||
})
|
||||
</script>
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedFilter" @update:model-value="handleFilterChange">
|
||||
<SelectTrigger class="w-[200px]">
|
||||
<SelectValue placeholder="Filter by task status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tasks</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Modeling</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`modeling:${status.id}`"
|
||||
:value="`modeling:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status.id as any" />
|
||||
<span>Modeling - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Surfacing</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`surfacing:${status.id}`"
|
||||
:value="`surfacing:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
<span>Surfacing - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Rigging</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`rigging:${status.id}`"
|
||||
:value="`rigging:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
<span>Rigging - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
v-if="selectedFilter && selectedFilter !== 'all'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearFilter"
|
||||
class="h-8 px-2"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'filter-changed', filter: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const selectedFilter = ref('all')
|
||||
|
||||
// Get loading state from store
|
||||
const isLoading = computed(() => {
|
||||
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
|
||||
})
|
||||
|
||||
// System status options (fallback if no project ID)
|
||||
const defaultStatusOptions = [
|
||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
|
||||
]
|
||||
|
||||
// Combine system and custom statuses
|
||||
const allStatuses = computed(() => {
|
||||
if (!props.projectId) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||
if (!statusData) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
// Convert system statuses to the format expected by TaskStatusBadge
|
||||
const systemStatusList = statusData.system_statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: status.is_system
|
||||
}))
|
||||
|
||||
// Convert custom statuses to the format expected by TaskStatusBadge
|
||||
const customStatusList = statusData.statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: false
|
||||
}))
|
||||
|
||||
return [...systemStatusList, ...customStatusList]
|
||||
})
|
||||
|
||||
// Load custom statuses when component mounts or projectId changes
|
||||
const loadStatuses = async () => {
|
||||
if (!props.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
const handleFilterChange = (filter: any) => {
|
||||
if (!filter) return
|
||||
const filterStr = String(filter)
|
||||
selectedFilter.value = filterStr
|
||||
// Convert "all" to empty string for the API
|
||||
const apiFilter = filterStr === 'all' ? '' : filterStr
|
||||
emit('filter-changed', apiFilter)
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
selectedFilter.value = 'all'
|
||||
emit('filter-changed', '')
|
||||
}
|
||||
</script>
|
||||
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
|
||||
onDelete: (asset: Asset) => void
|
||||
onViewTasks: (asset: Asset) => void
|
||||
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
||||
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
|
||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||
@@ -225,7 +226,9 @@ export const createAssetColumns = (
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id
|
||||
const taskDetail = asset.task_details?.find(t => t.task_type === taskType)
|
||||
const taskId = taskDetail?.task_id
|
||||
const assignedUserId = taskDetail?.assigned_user_id || null
|
||||
|
||||
return h(EditableTaskStatus, {
|
||||
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
||||
@@ -234,9 +237,13 @@ export const createAssetColumns = (
|
||||
status,
|
||||
taskId,
|
||||
projectId: meta.projectId,
|
||||
assignedUserId,
|
||||
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
|
||||
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
|
||||
},
|
||||
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
|
||||
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
|
||||
},
|
||||
})
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -297,8 +304,7 @@ export const createAssetColumns = (
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
size: 'icon-sm',
|
||||
onMouseDown: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const episodes = ref<Episode[]>([])
|
||||
@@ -108,11 +108,7 @@ const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const canCreateEpisodes = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const sortedEpisodes = computed(() => {
|
||||
return [...episodes.value].sort((a, b) => {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</p>
|
||||
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Episode
|
||||
New Episode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Upload Example</CardTitle>
|
||||
<CardDescription>
|
||||
Example showing how to integrate upload limit display and validation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Upload Limit Display -->
|
||||
<UploadLimitDisplay />
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-2">
|
||||
<Label for="file-upload">Select File</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
:disabled="uploading"
|
||||
accept=".mov,.mp4,.avi,.mkv,.webm,.jpg,.jpeg,.png,.exr,.tiff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div v-if="selectedFile" class="text-sm space-y-1">
|
||||
<p><strong>File:</strong> {{ selectedFile.name }}</p>
|
||||
<p><strong>Size:</strong> {{ formatFileSize(selectedFile.size) }}</p>
|
||||
<p><strong>Type:</strong> {{ getFileType(selectedFile.name) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation Errors -->
|
||||
<div v-if="validationError" class="text-sm text-destructive">
|
||||
{{ validationError }}
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<Button
|
||||
@click="uploadFile"
|
||||
:disabled="!selectedFile || !!validationError || uploading"
|
||||
class="w-full"
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 animate-spin mr-2" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
{{ uploading ? 'Uploading...' : 'Upload File' }}
|
||||
</Button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="uploadSuccess" class="text-sm text-green-600">
|
||||
File uploaded successfully!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Upload, Loader2 } from 'lucide-vue-next'
|
||||
import UploadLimitDisplay from '@/components/settings/UploadLimitDisplay.vue'
|
||||
import { validateFile, formatFileSize, isMovieFile, isImageFile } from '@/utils/fileValidation'
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const validationError = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const uploadSuccess = ref(false)
|
||||
|
||||
function getFileType(fileName: string): string {
|
||||
if (isMovieFile(fileName)) return 'Movie'
|
||||
if (isImageFile(fileName)) return 'Image'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
selectedFile.value = file
|
||||
uploadSuccess.value = false
|
||||
|
||||
// Validate file
|
||||
const result = await validateFile(file)
|
||||
validationError.value = result.isValid ? null : result.error || 'Invalid file'
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value || validationError.value) return
|
||||
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
// Simulate upload delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Here you would make the actual API call to upload the file
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', selectedFile.value)
|
||||
// await api.post('/tasks/123/attachments', formData)
|
||||
|
||||
uploadSuccess.value = true
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
|
||||
} catch (error) {
|
||||
validationError.value = 'Upload failed. Please try again.'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -8,13 +8,53 @@
|
||||
<Breadcrumb class="flex-1">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
|
||||
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
|
||||
<DropdownMenuTrigger
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
|
||||
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
|
||||
{{ project.name }}
|
||||
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<router-link to="/projects">All Projects</router-link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
|
||||
<DropdownMenuTrigger
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
:class="{ 'font-semibold text-foreground': crumb.isActive }"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
|
||||
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
|
||||
{{ tabItem.label }}
|
||||
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
|
||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
@@ -76,7 +116,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -97,10 +137,11 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { User, Settings, LogOut } from 'lucide-vue-next'
|
||||
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
||||
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
||||
import NotificationCenter from './NotificationCenter.vue'
|
||||
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
|
||||
// Generate breadcrumbs based on current route with enhanced context
|
||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||
|
||||
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
|
||||
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
|
||||
const projectIdParam = computed(() => {
|
||||
const id = route.params.projectId
|
||||
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
|
||||
})
|
||||
|
||||
const currentTab = computed(() => route.meta?.tab as string | undefined)
|
||||
|
||||
const projectTabItems = [
|
||||
{ tab: 'overview', label: 'Overview', path: '' },
|
||||
{ tab: 'shots', label: 'Shots', path: '/shots' },
|
||||
{ tab: 'assets', label: 'Assets', path: '/assets' },
|
||||
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
|
||||
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
|
||||
{ tab: 'settings', label: 'Settings', path: '/settings' }
|
||||
]
|
||||
|
||||
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
|
||||
const projectsStore = useProjectsStore()
|
||||
const projectsForSwitcher = computed(() => projectsStore.projects)
|
||||
|
||||
onMounted(() => {
|
||||
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||
projectsStore.fetchProjects()
|
||||
}
|
||||
})
|
||||
|
||||
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
|
||||
// so the trailing ">" separator can be skipped for it.
|
||||
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
|
||||
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
|
||||
if (crumb.isTabCrumb) return !!projectIdParam.value
|
||||
return false
|
||||
}
|
||||
|
||||
const updateBreadcrumbs = async () => {
|
||||
try {
|
||||
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<AppSidebar />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<SidebarInset class="flex-1 flex flex-col">
|
||||
<SidebarInset class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
|
||||
|
||||
@@ -27,12 +27,65 @@
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
|
||||
<!-- Collapsed sidebar + Projects (with an active project): hovering reveals sub-links,
|
||||
while the icon itself still navigates to /projects like any other nav item. -->
|
||||
<div
|
||||
v-if="item.title === 'Projects' && isCollapsed && projectTabs.length > 0"
|
||||
@mouseenter="showProjectFlyout = true"
|
||||
@mouseleave="showProjectFlyout = false"
|
||||
>
|
||||
<Popover :open="showProjectFlyout">
|
||||
<PopoverAnchor>
|
||||
<SidebarMenuButton as-child :title="item.title">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
class="w-48 p-1"
|
||||
@open-auto-focus.prevent
|
||||
@mouseenter="showProjectFlyout = true"
|
||||
@mouseleave="showProjectFlyout = false"
|
||||
>
|
||||
<router-link
|
||||
v-for="tab in projectTabs"
|
||||
:key="tab.id"
|
||||
:to="tab.route"
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
:class="activeProjectTab === tab.id && 'bg-accent text-accent-foreground'"
|
||||
@click="showProjectFlyout = false"
|
||||
>
|
||||
<component :is="tab.icon" class="size-4" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</router-link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<!-- Project sub-navigation - shown under "Projects" when inside a specific project -->
|
||||
<SidebarMenuSub v-if="item.title === 'Projects' && projectTabs.length > 0">
|
||||
<SidebarMenuSubItem v-for="tab in projectTabs" :key="tab.id">
|
||||
<SidebarMenuSubButton as-child :is-active="activeProjectTab === tab.id">
|
||||
<router-link :to="tab.route" class="flex items-center gap-2">
|
||||
<component :is="tab.icon" class="size-4" />
|
||||
<span class="truncate">{{ tab.label }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</template>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
@@ -93,8 +146,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -105,6 +159,9 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
SidebarProps
|
||||
} from '@/components/ui/sidebar'
|
||||
@@ -119,17 +176,25 @@ import {
|
||||
Key,
|
||||
Database,
|
||||
BarChart3,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
LayoutDashboard,
|
||||
Camera,
|
||||
Package,
|
||||
ListTodo,
|
||||
ShieldCheck,
|
||||
GanttChartSquare,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import ProjectSwitcher from './ProjectSwitcher.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { state } = useSidebar()
|
||||
const route = useRoute()
|
||||
const showProjectFlyout = ref(false)
|
||||
const user = computed(() => authStore.user)
|
||||
const userRole = computed(() => authStore.user?.role || 'artist')
|
||||
|
||||
@@ -138,7 +203,41 @@ const isCollapsed = computed(() => state.value === 'collapsed')
|
||||
|
||||
// Check if on shot page - show column switch only on shot pages
|
||||
const isOnShotPage = computed(() => {
|
||||
return route.path.includes('/shots') || route.path.includes('/project/')
|
||||
return route.path.endsWith('/shots') || route.path.includes('/project/')
|
||||
})
|
||||
|
||||
// The project id when on a specific project's page (null on /projects, /projects/new, etc.)
|
||||
const currentProjectId = computed(() => {
|
||||
const id = route.params.projectId
|
||||
const parsed = typeof id === 'string' ? parseInt(id) : NaN
|
||||
return isNaN(parsed) ? null : parsed
|
||||
})
|
||||
|
||||
// Sub-nav items shown under the "Projects" nav item when inside a specific project
|
||||
const projectTabs = computed(() => {
|
||||
const id = currentProjectId.value
|
||||
if (id === null) return []
|
||||
return [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, route: `/projects/${id}` },
|
||||
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
|
||||
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
|
||||
{ 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` },
|
||||
]
|
||||
})
|
||||
|
||||
const activeProjectTab = computed(() => {
|
||||
const id = currentProjectId.value
|
||||
if (id === null) return null
|
||||
const path = route.path
|
||||
if (path === `/projects/${id}`) return 'overview'
|
||||
if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
|
||||
if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
|
||||
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
|
||||
if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule'
|
||||
if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
|
||||
return null
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
@@ -151,7 +250,7 @@ const navigationItems = computed(() => {
|
||||
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
||||
]
|
||||
|
||||
if (userRole.value === 'coordinator' || authStore.isAdmin) {
|
||||
if (isCoordinatorOrAdmin.value) {
|
||||
baseItems.push(
|
||||
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
||||
{ title: 'Team', url: '/users', icon: Users }
|
||||
@@ -176,6 +275,7 @@ const navigationItems = computed(() => {
|
||||
// Admin-specific navigation items
|
||||
const adminItems = computed(() => [
|
||||
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
|
||||
{ title: 'Role Management', url: '/admin/roles', icon: ShieldCheck },
|
||||
])
|
||||
|
||||
// Developer-specific navigation items
|
||||
@@ -183,8 +283,7 @@ const developerItems = computed(() => [
|
||||
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
|
||||
{ title: 'All Projects', url: '/developer/projects', icon: Database },
|
||||
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
|
||||
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }
|
||||
])
|
||||
|
||||
// Mock recent projects - this would come from a store in real implementation
|
||||
|
||||
@@ -135,11 +135,13 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useProjectsStore } from "@/stores/projects";
|
||||
import { usePermission } from "@/composables/usePermission";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useSidebar();
|
||||
const authStore = useAuthStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const { isCoordinatorOrAdmin } = usePermission();
|
||||
|
||||
// Get projects and active project from store
|
||||
const projects = computed(() => projectsStore.availableProjects);
|
||||
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
|
||||
const error = computed(() => projectsStore.error);
|
||||
|
||||
// Check if user can create projects
|
||||
const canCreateProjects = computed(() => {
|
||||
const user = authStore.user;
|
||||
return user?.is_admin || user?.role === "coordinator";
|
||||
});
|
||||
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
|
||||
|
||||
const setActiveProject = (project: any) => {
|
||||
projectsStore.setActiveProject(project);
|
||||
|
||||
@@ -65,26 +65,11 @@
|
||||
<User class="size-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
|
||||
<Palette class="size-4" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleNotifications">
|
||||
<Bell class="size-4" />
|
||||
Notifications
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ notificationsEnabled ? 'On' : 'Off' }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Help and support -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/help')">
|
||||
<HelpCircle class="size-4" />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="showKeyboardShortcuts">
|
||||
<Keyboard class="size-4" />
|
||||
Keyboard Shortcuts
|
||||
@@ -99,6 +84,24 @@
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog v-model:open="showShortcutsDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Toggle sidebar</span>
|
||||
<span class="font-mono">⌘/Ctrl + B</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Quick search</span>
|
||||
<span class="font-mono">⌘/Ctrl + K</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -111,11 +114,8 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Keyboard,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
@@ -132,6 +132,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@@ -181,8 +187,7 @@ const showRoleFeatures = computed(() => {
|
||||
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
|
||||
})
|
||||
|
||||
// Notifications state (this would typically come from a notifications store)
|
||||
const notificationsEnabled = ref(true)
|
||||
const showShortcutsDialog = ref(false)
|
||||
|
||||
// Actions
|
||||
const navigateTo = (path: string) => {
|
||||
@@ -198,24 +203,8 @@ const handleLogout = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
notificationsEnabled.value = !notificationsEnabled.value
|
||||
// In a real app, this would update user preferences
|
||||
console.log('Notifications toggled:', notificationsEnabled.value)
|
||||
}
|
||||
|
||||
const showKeyboardShortcuts = () => {
|
||||
// In a real app, this would open a modal with keyboard shortcuts
|
||||
console.log('Keyboard shortcuts modal would open here')
|
||||
// For now, just show an alert with some common shortcuts
|
||||
alert(`Keyboard Shortcuts:
|
||||
|
||||
⌘/Ctrl + B - Toggle sidebar
|
||||
⌘/Ctrl + K - Quick search
|
||||
⌘/Ctrl + , - Open preferences
|
||||
⌘/Ctrl + / - Show help
|
||||
|
||||
More shortcuts available in the help documentation.`)
|
||||
showShortcutsDialog.value = true
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
|
||||
rigging: Wrench,
|
||||
surfacing: Paintbrush
|
||||
}
|
||||
return icons[department] || Box
|
||||
// Fallback for project-custom departments not in the standard icon map above
|
||||
return icons[department] || Tag
|
||||
}
|
||||
|
||||
const getFrameRateLabel = (frameRate: number) => {
|
||||
|
||||
@@ -41,14 +41,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
v-if="member.user_id"
|
||||
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -65,21 +58,21 @@
|
||||
<!-- Department Role -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm">Department:</Label>
|
||||
<select
|
||||
:value="member.department_role || 'none'"
|
||||
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
|
||||
<Select
|
||||
:model-value="member.department_role || 'none'"
|
||||
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
|
||||
:disabled="isUpdatingMember === member.id"
|
||||
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Joined Date -->
|
||||
@@ -89,8 +82,8 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -118,119 +111,84 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="closeAddDialog"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Add Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<!-- Add Member Dialog -->
|
||||
<Dialog v-model:open="showAddMemberDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a user to this project and assign their department role.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- User Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label for="user">User</Label>
|
||||
<select
|
||||
v-model="newMember.userId"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
<option
|
||||
v-for="user in (availableUsers || [])"
|
||||
<Label>User</Label>
|
||||
<Select v-model="newMember.userId" :disabled="isAddingMember">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Department Role -->
|
||||
<div class="space-y-2">
|
||||
<Label for="department">Department Role (Optional)</Label>
|
||||
<select
|
||||
v-model="newMember.departmentRole"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
<Label>Department Role (Optional)</Label>
|
||||
<Select v-model="newMember.departmentRole" :disabled="isAddingMember">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="closeAddDialog"
|
||||
:disabled="isAddingMember"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.userId || isAddingMember"
|
||||
>
|
||||
<Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
|
||||
<div v-if="isAddingMember" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span>Adding...</span>
|
||||
</div>
|
||||
<span v-else>Add Member</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Remove Member Confirmation -->
|
||||
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="showRemoveDialog = false"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Remove Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<AlertDialog v-model:open="showRemoveDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="showRemoveDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmRemoveMember"
|
||||
variant="destructive"
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -242,16 +200,42 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { userService } from '@/services/user'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
@@ -259,10 +243,24 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
const departmentRoles = computed(() => {
|
||||
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
|
||||
value: department,
|
||||
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||
}))
|
||||
})
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const availableUsers = ref<User[]>([])
|
||||
const allUsers = ref<User[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isUpdatingMember = ref<number | null>(null)
|
||||
@@ -273,7 +271,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
|
||||
|
||||
const newMember = ref({
|
||||
userId: '',
|
||||
departmentRole: ''
|
||||
departmentRole: 'none'
|
||||
})
|
||||
|
||||
// Computed
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map(m => m.user_id))
|
||||
return allUsers.value.filter(user => user.is_approved && !memberUserIds.has(user.id))
|
||||
})
|
||||
|
||||
// Methods
|
||||
@@ -284,40 +288,50 @@ const loadMembers = async () => {
|
||||
members.value = await projectService.getProjectMembers(props.projectId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load members'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load project members',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvailableUsers = async () => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const allUsers = await userService.getAllUsers()
|
||||
// Filter out users who are already members
|
||||
const memberUserIds = new Set(members.value.map(m => m.user_id))
|
||||
availableUsers.value = allUsers.filter(user =>
|
||||
user.is_approved && !memberUserIds.has(user.id)
|
||||
)
|
||||
allUsers.value = await userService.getUsers()
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
error.value = 'Failed to load available users'
|
||||
availableUsers.value = [] // Ensure it's always an array
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load users',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
|
||||
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
|
||||
try {
|
||||
isUpdatingMember.value = memberId
|
||||
await projectService.updateProjectMember(props.projectId, memberId, {
|
||||
isUpdatingMember.value = member.id
|
||||
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
|
||||
department_role: departmentRole as any
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const member = members.value.find(m => m.id === memberId)
|
||||
if (member) {
|
||||
member.department_role = departmentRole as any
|
||||
const index = members.value.findIndex(m => m.id === member.id)
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Role updated',
|
||||
description: 'Member department role has been updated',
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update member role'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update member role',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isUpdatingMember.value = null
|
||||
}
|
||||
@@ -330,7 +344,7 @@ const addMember = async () => {
|
||||
isAddingMember.value = true
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.userId),
|
||||
department_role: newMember.value.departmentRole || undefined
|
||||
department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
|
||||
}
|
||||
|
||||
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
|
||||
@@ -338,10 +352,16 @@ const addMember = async () => {
|
||||
|
||||
closeAddDialog()
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
toast({
|
||||
title: 'Member added',
|
||||
description: 'Team member has been added to the project',
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to add member'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to add member',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isAddingMember.value = false
|
||||
}
|
||||
@@ -358,19 +378,24 @@ const confirmRemoveMember = async () => {
|
||||
try {
|
||||
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
|
||||
|
||||
// Remove from local state
|
||||
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Member removed',
|
||||
description: 'Team member has been removed from the project',
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to remove member',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
showRemoveDialog.value = false
|
||||
memberToRemove.value = null
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to remove member'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +403,6 @@ const getUserInitials = (member: ProjectMember) => {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -391,25 +412,18 @@ const formatDate = (dateString: string) => {
|
||||
}
|
||||
|
||||
const openAddDialog = async () => {
|
||||
try {
|
||||
await loadAvailableUsers()
|
||||
await loadUsers()
|
||||
showAddMemberDialog.value = true
|
||||
} catch (err) {
|
||||
console.error('Failed to open add dialog:', err)
|
||||
error.value = 'Failed to load user list'
|
||||
}
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddMemberDialog.value = false
|
||||
// Reset form
|
||||
newMember.value = { userId: '', departmentRole: '' }
|
||||
newMember.value = { userId: '', departmentRole: 'none' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||
})
|
||||
</script>
|
||||
@@ -1,323 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Add Member Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">Add Team Member</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Select
|
||||
:model-value="newMember.user_id"
|
||||
@update:model-value="newMember.user_id = $event"
|
||||
class="flex-1"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="newMember.department_role"
|
||||
@update:model-value="newMember.department_role = $event"
|
||||
class="w-40"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.user_id || isLoading"
|
||||
size="sm"
|
||||
>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Current Members -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">
|
||||
Current Members ({{ members.length }})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading && members.length === 0"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">Loading members...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
<User class="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ member.user_email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
:model-value="member.department_role || ''"
|
||||
@update:model-value="(value) => updateMemberRole(member, value)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="removeMember(member)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No members assigned to this project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<Button @click="$emit('close')" variant="outline"> Close </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { Plus, User, Users, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import { projectService, type ProjectMember } from "@/services/project";
|
||||
import { userService } from "@/services/user";
|
||||
import type { User as UserType } from "@/types/auth";
|
||||
import type { Project } from "@/stores/projects";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([]);
|
||||
const allUsers = ref<UserType[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const newMember = ref({
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
});
|
||||
|
||||
// Computed
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadMembers = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
members.value = await projectService.getProjectMembers(props.project.id);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load project members",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
allUsers.value = await userService.getUsers();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load users",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember.value.user_id) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.user_id),
|
||||
department_role: newMember.value.department_role || undefined,
|
||||
};
|
||||
|
||||
const addedMember = await projectService.addProjectMember(
|
||||
props.project.id,
|
||||
memberData
|
||||
);
|
||||
members.value.push(addedMember);
|
||||
|
||||
// Reset form
|
||||
newMember.value = {
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
};
|
||||
|
||||
toast({
|
||||
title: "Member added",
|
||||
description: "Team member has been added to the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to add member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const updatedMember = await projectService.updateProjectMember(
|
||||
props.project.id,
|
||||
member.id,
|
||||
{ department_role: newRole || undefined }
|
||||
);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Role updated",
|
||||
description: "Member department role has been updated",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update member role",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member: ProjectMember) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await projectService.removeProjectMember(props.project.id, member.id);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Member removed",
|
||||
description: "Team member has been removed from the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers();
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
@@ -1,178 +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;
|
||||
|
||||
// Debug logging
|
||||
console.log('ProjectTabs - Current path:', currentPath);
|
||||
console.log('ProjectTabs - Project ID:', props.projectId);
|
||||
|
||||
if (currentPath === `/projects/${props.projectId}`) {
|
||||
console.log('ProjectTabs - Active tab: overview');
|
||||
return "overview";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
|
||||
console.log('ProjectTabs - Active tab: shots');
|
||||
return "shots";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
|
||||
console.log('ProjectTabs - Active tab: assets');
|
||||
return "assets";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
|
||||
console.log('ProjectTabs - Active tab: tasks');
|
||||
return "tasks";
|
||||
} else if (
|
||||
currentPath.startsWith(`/projects/${props.projectId}/settings`)
|
||||
) {
|
||||
console.log('ProjectTabs - Active tab: settings');
|
||||
return "settings";
|
||||
}
|
||||
|
||||
console.log('ProjectTabs - Active tab: overview (default)');
|
||||
return "overview";
|
||||
});
|
||||
|
||||
// Set active tab and navigate
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const tab = tabs.value.find((t) => t.id === tabId);
|
||||
console.log('ProjectTabs - Setting active tab:', tabId);
|
||||
console.log('ProjectTabs - Tab found:', tab);
|
||||
console.log('ProjectTabs - Current route path:', route.path);
|
||||
console.log('ProjectTabs - Target route:', tab?.route);
|
||||
|
||||
if (tab) {
|
||||
console.log('ProjectTabs - Navigating to:', tab.route);
|
||||
router.push(tab.route).catch(err => {
|
||||
console.error('ProjectTabs - Navigation error:', err);
|
||||
});
|
||||
} else {
|
||||
console.log('ProjectTabs - Tab not found');
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Table Header Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ episodeId ? `Episode ${episodeId} Shots` : "All Shots" }}
|
||||
</h3>
|
||||
<Badge variant="secondary" v-if="shots.length > 0">
|
||||
{{ shots.length }} shot{{ shots.length !== 1 ? "s" : "" }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="refreshShots">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Shot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
|
||||
></div>
|
||||
<span class="text-muted-foreground">Loading shots...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
|
||||
<p class="text-muted-foreground">{{ error }}</p>
|
||||
<Button variant="outline" size="sm" @click="refreshShots" class="mt-2">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="shots.length === 0" class="text-center py-12">
|
||||
<Camera class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 class="text-lg font-semibold mb-2">No shots found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{
|
||||
episodeId
|
||||
? "This episode doesn't have any shots yet."
|
||||
: "No shots found for the selected criteria."
|
||||
}}
|
||||
</p>
|
||||
<Button @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create First Shot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shots Table -->
|
||||
<div v-else class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Shot Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Frames</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Tasks</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead class="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="shot in shots"
|
||||
:key="shot.id"
|
||||
class="hover:bg-muted/50"
|
||||
>
|
||||
<TableCell class="font-medium">{{ shot.name }}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
v-if="shot.description"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ shot.description }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground italic"
|
||||
>No description</span
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="font-mono text-sm">
|
||||
{{ shot.frame_start }}-{{ shot.frame_end }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground ml-2">
|
||||
({{ shot.frame_end - shot.frame_start + 1 }} frames)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getStatusVariant(shot.status)">
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm">{{ shot.task_count }} tasks</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(shot.updated_at) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="editShot(shot)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="viewTasks(shot)">
|
||||
<CheckSquare class="h-4 w-4 mr-2" />
|
||||
View Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="deleteShot(shot)"
|
||||
class="text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import {
|
||||
Camera,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
CheckSquare,
|
||||
Trash2,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { shotService, type Shot, ShotStatus } from "@/services/shot";
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
episodeId?: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Reactive state
|
||||
const shots = ref<Shot[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Methods
|
||||
const loadShots = async () => {
|
||||
if (!props.projectId) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const shotsData = await shotService.getShots(
|
||||
props.projectId,
|
||||
props.episodeId || undefined
|
||||
);
|
||||
shots.value = shotsData;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to load shots";
|
||||
shots.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshShots = () => {
|
||||
loadShots();
|
||||
};
|
||||
|
||||
const createShot = () => {
|
||||
// TODO: Implement shot creation dialog
|
||||
console.log("Create shot for episode:", props.episodeId);
|
||||
};
|
||||
|
||||
const editShot = (shot: Shot) => {
|
||||
// TODO: Implement shot editing
|
||||
console.log("Edit shot:", shot);
|
||||
};
|
||||
|
||||
const viewTasks = (shot: Shot) => {
|
||||
// TODO: Navigate to shot tasks view
|
||||
console.log("View tasks for shot:", shot);
|
||||
};
|
||||
|
||||
const deleteShot = async (shot: Shot) => {
|
||||
// TODO: Implement shot deletion with confirmation
|
||||
console.log("Delete shot:", shot);
|
||||
};
|
||||
|
||||
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 formatStatus = (status: ShotStatus) => {
|
||||
return status
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => [props.projectId, props.episodeId],
|
||||
() => {
|
||||
loadShots();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadShots();
|
||||
});
|
||||
</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>
|
||||
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import { notificationService } from '@/services/notifications'
|
||||
@@ -97,7 +97,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
@@ -108,11 +108,7 @@ const isEditing = ref(false)
|
||||
const showNotification = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
// Methods
|
||||
const loadSpecs = async () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
||||
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
||||
@@ -73,7 +73,7 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// State
|
||||
const isOpen = ref(props.defaultOpen || false)
|
||||
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
|
||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const hasSpecs = computed(() => {
|
||||
if (!specs.value) return false
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ role?.is_system
|
||||
? 'System role permissions are editable, but the name and description cannot be changed.'
|
||||
: 'Define a role and the permissions it grants.' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="role_name">Name</Label>
|
||||
<Input
|
||||
id="role_name"
|
||||
v-model="formData.name"
|
||||
placeholder="e.g. Reviewer"
|
||||
required
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="role_description">Description</Label>
|
||||
<Input
|
||||
id="role_description"
|
||||
v-model="formData.description"
|
||||
placeholder="What this role is for"
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between flex-shrink-0">
|
||||
<Label>Permissions</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
|
||||
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 text-sm font-medium capitalize">
|
||||
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
|
||||
{{ resource }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
@click="toggleAllForResource(resource)"
|
||||
>
|
||||
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<label
|
||||
v-for="perm in permissionsForResource(resource)"
|
||||
:key="perm.id"
|
||||
class="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
:title="perm.description || undefined"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedPermissionIds.has(perm.id)"
|
||||
@update:model-value="(val) => togglePermission(perm.id, !!val)"
|
||||
/>
|
||||
{{ actionLabel(perm.action) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
|
||||
|
||||
<DialogFooter class="flex-shrink-0">
|
||||
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
||||
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { Role, Permission } from '@/services/role'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
role: Role | null
|
||||
permissions: Permission[]
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
|
||||
}>()
|
||||
|
||||
const formData = ref({ name: '', description: '' })
|
||||
const selectedPermissionIds = ref<Set<number>>(new Set())
|
||||
const formError = ref('')
|
||||
|
||||
// Resources grouped into scannable sections, in a fixed, deliberate order
|
||||
// (not alphabetical) so related concepts sit together.
|
||||
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
|
||||
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
|
||||
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
|
||||
{ title: 'Review', resources: ['review'] },
|
||||
{ title: 'Notes', resources: ['note'] },
|
||||
]
|
||||
|
||||
const RESOURCE_ICONS: Record<string, any> = {
|
||||
asset: Package,
|
||||
shot: Camera,
|
||||
task: ListTodo,
|
||||
assignment: UserCheck,
|
||||
submission: UploadCloud,
|
||||
upload: Paperclip,
|
||||
review: CheckCircle2,
|
||||
note: MessageSquare,
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
publish: 'Approve',
|
||||
retake: 'Request Retake',
|
||||
view_internal: 'View Internal',
|
||||
view_client: 'View Client',
|
||||
change_status: 'Change Status',
|
||||
edit_self: 'Edit Own',
|
||||
delete_self: 'Delete Own',
|
||||
edit_other: "Edit Others'",
|
||||
delete_other: "Delete Others'",
|
||||
}
|
||||
|
||||
function resourceIcon(resource: string) {
|
||||
return RESOURCE_ICONS[resource] ?? Shield
|
||||
}
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? action
|
||||
}
|
||||
|
||||
// Only show groups/resources that actually have permissions in the catalog,
|
||||
// and fall back to a catch-all group for any future resource not yet
|
||||
// assigned to a section above (so nothing silently disappears from the UI).
|
||||
const permissionGroups = computed(() => {
|
||||
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
|
||||
const groups = RESOURCE_GROUPS
|
||||
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
|
||||
.filter(g => g.resources.length > 0)
|
||||
|
||||
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
|
||||
if (otherResources.length > 0) {
|
||||
groups.push({ title: 'Other', resources: otherResources })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function permissionsForResource(resource: string): Permission[] {
|
||||
return props.permissions.filter(p => p.resource === resource)
|
||||
}
|
||||
|
||||
function allSelectedForResource(resource: string): boolean {
|
||||
const perms = permissionsForResource(resource)
|
||||
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
|
||||
}
|
||||
|
||||
function toggleAllForResource(resource: string) {
|
||||
const perms = permissionsForResource(resource)
|
||||
const shouldSelect = !allSelectedForResource(resource)
|
||||
for (const perm of perms) {
|
||||
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
|
||||
else selectedPermissionIds.value.delete(perm.id)
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(id: number, checked: boolean) {
|
||||
if (checked) selectedPermissionIds.value.add(id)
|
||||
else selectedPermissionIds.value.delete(id)
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
formError.value = ''
|
||||
formData.value = {
|
||||
name: props.role?.name ?? '',
|
||||
description: props.role?.description ?? ''
|
||||
}
|
||||
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
formError.value = ''
|
||||
if (!props.role && !formData.value.name.trim()) {
|
||||
formError.value = 'Name is required'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = props.role?.is_system
|
||||
? { permission_ids: [...selectedPermissionIds.value] }
|
||||
: {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description || undefined,
|
||||
permission_ids: [...selectedPermissionIds.value]
|
||||
}
|
||||
emit('saved', payload)
|
||||
}
|
||||
</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">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Custom Task Types</h3>
|
||||
<h3 class="text-lg font-semibold">Task Types</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Add custom task types beyond the standard types to adapt the pipeline to your project needs
|
||||
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -30,33 +30,30 @@
|
||||
|
||||
<!-- Asset Task Types List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="assetTaskTypes.length > 0">
|
||||
<template v-if="assetTaskTypeRows.length > 0">
|
||||
<div
|
||||
v-for="taskType in assetTaskTypes"
|
||||
:key="taskType"
|
||||
v-for="row in assetTaskTypeRows"
|
||||
:key="row.name"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
||||
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
|
||||
Standard
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
|
||||
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
|
||||
<Badge v-else variant="outline">Custom</Badge>
|
||||
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
|
||||
</div>
|
||||
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
|
||||
<div v-if="!row.isStandard" class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog('asset', taskType)"
|
||||
@click="openEditDialog('asset', row.name, row.department)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete('asset', taskType)"
|
||||
@click="handleDelete('asset', row.name, row.department)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -86,33 +83,30 @@
|
||||
|
||||
<!-- Shot Task Types List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="shotTaskTypes.length > 0">
|
||||
<template v-if="shotTaskTypeRows.length > 0">
|
||||
<div
|
||||
v-for="taskType in shotTaskTypes"
|
||||
:key="taskType"
|
||||
v-for="row in shotTaskTypeRows"
|
||||
:key="row.name"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
||||
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
|
||||
Standard
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
|
||||
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
|
||||
<Badge v-else variant="outline">Custom</Badge>
|
||||
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
|
||||
</div>
|
||||
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
|
||||
<div v-if="!row.isStandard" class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog('shot', taskType)"
|
||||
@click="openEditDialog('shot', row.name, row.department)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete('shot', taskType)"
|
||||
@click="handleDelete('shot', row.name, row.department)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -155,7 +149,25 @@
|
||||
{{ validationError }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
3-50 characters, lowercase alphanumeric with underscores only
|
||||
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogMode === 'add'" class="space-y-2">
|
||||
<Label>Department (optional)</Label>
|
||||
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None (general purpose)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (general purpose)</SelectItem>
|
||||
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
|
||||
{{ formatTaskTypeName(department.name) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,6 +193,7 @@
|
||||
// Only clear values when dialog closes and we're not in the middle of deleting
|
||||
taskTypeToDelete = ''
|
||||
categoryToDelete = ''
|
||||
departmentToDelete = ''
|
||||
deleteError = ''
|
||||
}
|
||||
}"
|
||||
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -237,6 +251,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
|
||||
import { departmentService, type DepartmentInfo } from '@/services/department'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
@@ -250,6 +266,13 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
|
||||
interface TaskTypeRow {
|
||||
name: string
|
||||
isStandard: boolean
|
||||
department?: string
|
||||
}
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
|
||||
const isDialogOpen = ref(false)
|
||||
const dialogMode = ref<'add' | 'edit'>('add')
|
||||
const dialogCategory = ref<'asset' | 'shot'>('asset')
|
||||
const dialogDepartment = ref('')
|
||||
const editingDepartment = ref('')
|
||||
const taskTypeName = ref('')
|
||||
const originalTaskTypeName = ref('')
|
||||
const validationError = ref('')
|
||||
@@ -268,6 +293,7 @@ const isSaving = ref(false)
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const taskTypeToDelete = ref('')
|
||||
const categoryToDelete = ref<'asset' | 'shot'>('asset')
|
||||
const departmentToDelete = ref('')
|
||||
const deleteError = ref('')
|
||||
const isDeleting = ref(false)
|
||||
|
||||
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
|
||||
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
|
||||
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
|
||||
|
||||
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
|
||||
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
|
||||
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
|
||||
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
|
||||
const dialogCustomDepartments = computed(() => {
|
||||
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
|
||||
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
|
||||
})
|
||||
|
||||
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
|
||||
const rows = new Map<string, TaskTypeRow>()
|
||||
for (const t of flatAll) {
|
||||
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
|
||||
}
|
||||
for (const department of departments) {
|
||||
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
|
||||
for (const t of department.task_types) {
|
||||
const existing = rows.get(t)
|
||||
if (existing) {
|
||||
existing.department = department.name
|
||||
existing.isStandard = existing.isStandard || isDeptStandard
|
||||
} else {
|
||||
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(rows.values())
|
||||
}
|
||||
|
||||
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
|
||||
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
|
||||
|
||||
const isTaskTypeNameValid = computed(() => {
|
||||
return taskTypeName.value.length >= 3 && !validationError.value
|
||||
const minLength = dialogDepartment.value ? 2 : 3
|
||||
return taskTypeName.value.length >= minLength && !validationError.value
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadTaskTypes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||
const [types] = await Promise.all([
|
||||
customTaskTypeService.getAllTaskTypes(props.projectId),
|
||||
departmentsStore.fetchProjectDepartments(props.projectId, true)
|
||||
])
|
||||
taskTypes.value = types
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load task types:', error)
|
||||
toast({
|
||||
@@ -298,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 => {
|
||||
return taskType.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const validateTaskTypeName = () => {
|
||||
const name = taskTypeName.value.trim()
|
||||
const minLength = dialogDepartment.value ? 2 : 3
|
||||
|
||||
if (name.length === 0) {
|
||||
validationError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length < 3) {
|
||||
validationError.value = 'Task type name must be at least 3 characters'
|
||||
if (name.length < minLength) {
|
||||
validationError.value = `Task type name must be at least ${minLength} characters`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -333,10 +389,10 @@ const validateTaskTypeName = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicates (only if adding or changing name)
|
||||
// Check for duplicates against the merged (flat + department) rows for this category
|
||||
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
|
||||
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
|
||||
if (existingTypes.includes(name)) {
|
||||
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
|
||||
if (existingRows.some(row => row.name === name)) {
|
||||
validationError.value = 'A task type with this name already exists'
|
||||
return
|
||||
}
|
||||
@@ -348,15 +404,17 @@ const validateTaskTypeName = () => {
|
||||
const openAddDialog = (category: 'asset' | 'shot') => {
|
||||
dialogMode.value = 'add'
|
||||
dialogCategory.value = category
|
||||
dialogDepartment.value = ''
|
||||
taskTypeName.value = ''
|
||||
originalTaskTypeName.value = ''
|
||||
validationError.value = ''
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
|
||||
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
|
||||
dialogMode.value = 'edit'
|
||||
dialogCategory.value = category
|
||||
editingDepartment.value = department || ''
|
||||
taskTypeName.value = taskType
|
||||
originalTaskTypeName.value = taskType
|
||||
validationError.value = ''
|
||||
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
|
||||
|
||||
const closeDialog = () => {
|
||||
isDialogOpen.value = false
|
||||
dialogDepartment.value = ''
|
||||
editingDepartment.value = ''
|
||||
taskTypeName.value = ''
|
||||
originalTaskTypeName.value = ''
|
||||
validationError.value = ''
|
||||
@@ -381,17 +441,30 @@ const handleDialogSave = async () => {
|
||||
isSaving.value = true
|
||||
|
||||
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, {
|
||||
task_type: taskTypeName.value.trim(),
|
||||
category: dialogCategory.value
|
||||
})
|
||||
console.log('Add task type response:', response)
|
||||
taskTypes.value = response
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
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 {
|
||||
const response = await customTaskTypeService.updateCustomTaskType(
|
||||
props.projectId,
|
||||
@@ -402,8 +475,8 @@ const handleDialogSave = async () => {
|
||||
category: dialogCategory.value
|
||||
}
|
||||
)
|
||||
console.log('Update task type response:', response)
|
||||
taskTypes.value = response
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
@@ -427,22 +500,15 @@ const handleDialogSave = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
|
||||
console.log('=== HANDLE DELETE ===')
|
||||
console.log('Received taskType:', taskType)
|
||||
console.log('Received category:', category)
|
||||
|
||||
const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
|
||||
taskTypeToDelete.value = taskType
|
||||
categoryToDelete.value = category
|
||||
departmentToDelete.value = department || ''
|
||||
deleteError.value = ''
|
||||
isDeleteDialogOpen.value = true
|
||||
|
||||
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
|
||||
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
console.log('=== CLOSE DELETE DIALOG ===')
|
||||
isDeleteDialogOpen.value = false
|
||||
// Values will be cleared by @update:open handler
|
||||
}
|
||||
@@ -451,36 +517,29 @@ const confirmDelete = async () => {
|
||||
// Capture values immediately before any async operations
|
||||
const taskTypeToDeleteLocal = taskTypeToDelete.value
|
||||
const categoryToDeleteLocal = categoryToDelete.value
|
||||
const departmentToDeleteLocal = departmentToDelete.value
|
||||
|
||||
try {
|
||||
isDeleting.value = true
|
||||
deleteError.value = ''
|
||||
|
||||
console.log('=== DELETE DEBUG ===')
|
||||
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
|
||||
console.log('categoryToDelete.value:', categoryToDeleteLocal)
|
||||
console.log('projectId:', props.projectId)
|
||||
|
||||
if (!taskTypeToDeleteLocal) {
|
||||
console.error('ERROR: taskTypeToDelete is empty!')
|
||||
deleteError.value = 'Task type name is missing. Please try again.'
|
||||
isDeleting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Deleting task type:', {
|
||||
projectId: props.projectId,
|
||||
taskType: taskTypeToDeleteLocal,
|
||||
category: categoryToDeleteLocal
|
||||
})
|
||||
|
||||
if (departmentToDeleteLocal) {
|
||||
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
|
||||
departmentsStore.updateProjectDepartments(props.projectId, response)
|
||||
} else {
|
||||
const response = await customTaskTypeService.deleteCustomTaskType(
|
||||
props.projectId,
|
||||
taskTypeToDeleteLocal,
|
||||
categoryToDeleteLocal
|
||||
)
|
||||
console.log('Delete task type response:', response)
|
||||
taskTypes.value = response
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
@@ -492,16 +551,20 @@ const confirmDelete = async () => {
|
||||
// Close dialog and clear values
|
||||
isDeleteDialogOpen.value = false
|
||||
taskTypeToDelete.value = ''
|
||||
categoryToDelete.value = ''
|
||||
categoryToDelete.value = 'asset'
|
||||
departmentToDelete.value = ''
|
||||
deleteError.value = ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete task type:', error)
|
||||
const errorData = error.response?.data
|
||||
const detail = errorData?.detail
|
||||
|
||||
if (errorData?.task_count) {
|
||||
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
|
||||
if (detail?.task_count) {
|
||||
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
|
||||
} else if (typeof detail === 'string') {
|
||||
deleteError.value = detail
|
||||
} else {
|
||||
deleteError.value = errorData?.detail || 'Failed to delete task type'
|
||||
deleteError.value = 'Failed to delete task type'
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -100,7 +100,7 @@
|
||||
</p>
|
||||
<Button @click="showCreateDialog = true" v-if="canManage">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Episode
|
||||
New Episode
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineEmits<{ clear: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 lg:px-3" @click="$emit('clear')">
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Settings2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CheckableCommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
|
||||
export interface ColumnOption {
|
||||
id: string
|
||||
label: string
|
||||
/** Optional section label; columns without one are grouped first, unlabeled. */
|
||||
group?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: ColumnOption[]
|
||||
modelValue: VisibilityState
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: VisibilityState]
|
||||
}>()
|
||||
|
||||
const isVisible = (columnId: string) => props.modelValue[columnId] !== false
|
||||
|
||||
const toggle = (columnId: string, value: boolean) => {
|
||||
emit('update:modelValue', { ...props.modelValue, [columnId]: value })
|
||||
}
|
||||
|
||||
const hiddenCount = computed(() => props.columns.filter(col => !isVisible(col.id)).length)
|
||||
|
||||
const groupedColumns = computed(() => {
|
||||
const groups = new Map<string | undefined, ColumnOption[]>()
|
||||
for (const column of props.columns) {
|
||||
const key = column.group
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(column)
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, columns]) => ({ label, columns }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<template v-for="group in groupedColumns" :key="group.label ?? '__default__'">
|
||||
<CommandGroup>
|
||||
<div v-if="group.label" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<CommandSeparator v-if="group.label" />
|
||||
<CheckableCommandItem
|
||||
v-for="column in group.columns"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
:model-value="isVisible(column.id)"
|
||||
@update:model-value="(value) => toggle(column.id, value)"
|
||||
>
|
||||
<span>{{ column.label }}</span>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps<{ title: string; message: string }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-center">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">{{ title }}</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ message }}</p>
|
||||
<Button @click="$emit('retry')" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': deletedAt }">{{ title }}</h2>
|
||||
<slot name="badges" />
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon-sm" class="flex-shrink-0" @click="$emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
mobileOpen: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
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 />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Sheet :open="mobileOpen" @update:open="emit('update:mobileOpen', $event)">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<slot />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps<{ enabled: boolean }>()
|
||||
defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
@click="$emit('toggle')"
|
||||
:variant="enabled ? 'default' : 'outline'"
|
||||
size="icon-sm"
|
||||
:class="enabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
:title="enabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
>
|
||||
<PanelRightClose v-if="enabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export interface SegmentOption {
|
||||
value: string
|
||||
label?: string
|
||||
icon?: unknown
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: SegmentOption[]
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': modelValue === option.value }"
|
||||
:title="option.title"
|
||||
class="h-7 px-2"
|
||||
@click="$emit('update:modelValue', option.value)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" class="h-4 w-4" />
|
||||
<template v-if="option.label">{{ option.label }}</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
+101
-158
@@ -1,3 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CheckableCommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-changed': [filter: string]
|
||||
}>()
|
||||
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const selectedFilters = ref<string[]>([])
|
||||
|
||||
// System status options (fallback if no project ID / statuses not loaded yet)
|
||||
const defaultStatusOptions = [
|
||||
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
|
||||
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
|
||||
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
|
||||
{ id: 'approved', name: 'Approved', color: '', is_system: true },
|
||||
{ id: 'retake', name: 'Retake', color: '', is_system: true },
|
||||
]
|
||||
|
||||
const allStatuses = computed(() => {
|
||||
if (!props.projectId) return defaultStatusOptions
|
||||
|
||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||
if (!statusData) return defaultStatusOptions
|
||||
|
||||
const systemStatusList = statusData.system_statuses.map(s => ({
|
||||
id: s.id, name: s.name, color: s.color, is_system: s.is_system,
|
||||
}))
|
||||
const customStatusList = statusData.statuses.map(s => ({
|
||||
id: s.id, name: s.name, color: s.color, is_system: false,
|
||||
}))
|
||||
return [...systemStatusList, ...customStatusList]
|
||||
})
|
||||
|
||||
const loadStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatuses)
|
||||
watch(() => props.projectId, loadStatuses)
|
||||
|
||||
const toggleFilter = (filter: string) => {
|
||||
const index = selectedFilters.value.indexOf(filter)
|
||||
if (index > -1) {
|
||||
selectedFilters.value.splice(index, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(filter)
|
||||
}
|
||||
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
|
||||
emit('filter-changed', apiFilter)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
selectedFilters.value = []
|
||||
emit('filter-changed', '')
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) =>
|
||||
taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
@@ -21,184 +113,35 @@
|
||||
|
||||
<!-- All Tasks Option -->
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
value="all"
|
||||
@select="clearAllFilters"
|
||||
:model-value="selectedFilters.length === 0"
|
||||
@update:model-value="clearAllFilters"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
selectedFilters.length === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Tasks</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<!-- Task Type Groups -->
|
||||
<CommandGroup
|
||||
v-for="taskType in allTaskTypes"
|
||||
:key="taskType"
|
||||
>
|
||||
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
|
||||
<CommandSeparator />
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ formatTaskType(taskType) }}
|
||||
</div>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`${taskType}:${status.id}`"
|
||||
:value="`${taskType}:${status.id}`"
|
||||
@select="toggleFilter(`${taskType}:${status.id}`)"
|
||||
:model-value="selectedFilters.includes(`${taskType}:${status.id}`)"
|
||||
@update:model-value="toggleFilter(`${taskType}:${status.id}`)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
selectedFilters.includes(`${taskType}:${status.id}`)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ListFilter, Check } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/shot'
|
||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-changed': [filter: string]
|
||||
}>()
|
||||
|
||||
// Use the shared task statuses store
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
|
||||
// Get loading state from store
|
||||
const isLoading = computed(() => {
|
||||
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
|
||||
})
|
||||
|
||||
// System status options (fallback if no project ID)
|
||||
const defaultStatusOptions = [
|
||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
|
||||
]
|
||||
|
||||
// Combine system and custom statuses
|
||||
const allStatuses = computed(() => {
|
||||
if (!props.projectId) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||
if (!statusData) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
// Convert system statuses to the format expected by TaskStatusBadge
|
||||
const systemStatusList = statusData.system_statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: status.is_system
|
||||
}))
|
||||
|
||||
// Convert custom statuses to the format expected by TaskStatusBadge
|
||||
const customStatusList = statusData.statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: false
|
||||
}))
|
||||
|
||||
return [...systemStatusList, ...customStatusList]
|
||||
})
|
||||
|
||||
// Load custom statuses when component mounts or projectId changes
|
||||
const loadStatuses = async () => {
|
||||
if (!props.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
const toggleFilter = (filter: string) => {
|
||||
const index = selectedFilters.value.indexOf(filter)
|
||||
if (index > -1) {
|
||||
selectedFilters.value.splice(index, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(filter)
|
||||
}
|
||||
|
||||
// Emit the filters as a comma-separated string, or empty string if none selected
|
||||
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
|
||||
emit('filter-changed', apiFilter)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
selectedFilters.value = []
|
||||
emit('filter-changed', '')
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const projectMembersStore = useProjectMembersStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filtered project members based on search query
|
||||
@@ -306,27 +308,18 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
// Load project members (shared cache across all EditableTaskStatus instances for this project)
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
console.log('Loading project members for project:', props.projectId)
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
console.log('Loaded project members:', projectMembers.value)
|
||||
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
} finally {
|
||||
isLoadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure members are loaded when popover is about to open
|
||||
const ensureMembersLoaded = () => {
|
||||
console.log('Ensuring project members are loaded')
|
||||
if (projectMembers.value.length === 0) {
|
||||
console.log('Loading project members on button click')
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
@@ -407,7 +400,6 @@ onMounted(() => {
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
loadProjectMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<div class="relative h-full flex flex-col">
|
||||
<!-- Main Content -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<ShotTableToolbar
|
||||
:view-mode="viewMode"
|
||||
:episode-filter="episodeFilter"
|
||||
@@ -14,12 +14,14 @@
|
||||
:project-id="projectId"
|
||||
:selected-shot="selectedShot"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
:is-columns-locked="lockColumns"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:episode-filter="handleEpisodeFilterChange"
|
||||
@update:search="searchQuery = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@task-status-filter-changed="handleTaskStatusFilter"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
@toggle-column-lock="toggleColumnLock"
|
||||
@bulk-create="showBulkCreateDialog = true"
|
||||
@create-shot="showCreateDialog = true"
|
||||
/>
|
||||
@@ -76,11 +78,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Shots Grid/List/Table -->
|
||||
<div v-else>
|
||||
<div v-else class="flex-1 min-h-0">
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6 h-full overflow-auto"
|
||||
>
|
||||
<ShotCard
|
||||
v-for="shot in filteredShots"
|
||||
@@ -93,7 +95,7 @@
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6">
|
||||
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6 h-full overflow-auto">
|
||||
<div
|
||||
v-for="shot in filteredShots"
|
||||
:key="shot.id"
|
||||
@@ -118,7 +120,7 @@
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -148,55 +150,43 @@
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
:lock-columns="lockColumns"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
@row-dblclick="handleRowDoubleClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedShot"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
<!-- Detail Panel (Overlay - Desktop + Mobile) -->
|
||||
<DetailPanelOverlay
|
||||
:visible="!!(showPanel && selectedShot)"
|
||||
v-model:mobile-open="showMobileDetail"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
:initial-reply-note-id="selectedTaskReplyNoteId"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadShots"
|
||||
/>
|
||||
<ShotDetailPanel
|
||||
v-else-if="selectedShot"
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
:initial-shot="selectedShot"
|
||||
:all-task-types="allTaskTypes"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@close="closeDetailPanel"
|
||||
/>
|
||||
</DetailPanelOverlay>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Detail Sheet -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<ShotDetailPanel
|
||||
v-if="selectedShot"
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
<!-- Dialogs -->
|
||||
<div class="space-y-4">
|
||||
@@ -278,6 +268,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, shallowRef, markRaw, nextTick, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Search, Plus, Camera, AlertCircle, RefreshCw,
|
||||
Layers, MoreHorizontal, Edit, ListTodo, Trash2
|
||||
@@ -293,10 +284,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
} from '@/components/ui/sheet'
|
||||
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
||||
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -309,6 +297,7 @@ import ShotCard from './ShotCard.vue'
|
||||
import ShotForm from './ShotForm.vue'
|
||||
import BulkShotForm from './BulkShotForm.vue'
|
||||
import ShotDetailPanel from './ShotDetailPanel.vue'
|
||||
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
||||
import ShotsDataTable from './ShotsDataTable.vue'
|
||||
import ShotTableToolbar from './ShotTableToolbar.vue'
|
||||
import { createShotColumns, type ShotColumnMeta } from './columns'
|
||||
@@ -336,6 +325,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Composables
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
@@ -355,6 +345,13 @@ const {
|
||||
sessionStorageKey: 'shotBrowser.detailPanelEnabled'
|
||||
})
|
||||
|
||||
// Handle double-click to navigate to shot detail page
|
||||
const handleRowDoubleClick = (shot: Shot) => {
|
||||
if (props.projectId) {
|
||||
router.push(`/projects/${props.projectId}/shots/${shot.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive state
|
||||
const shots = ref<Shot[]>([])
|
||||
const isLoading = ref(false)
|
||||
@@ -383,6 +380,13 @@ const columnVisibilityStore = useColumnVisibilityStore()
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
|
||||
const lockColumns = ref(localStorage.getItem('shot-columns-locked') === 'true')
|
||||
const toggleColumnLock = () => {
|
||||
lockColumns.value = !lockColumns.value
|
||||
localStorage.setItem('shot-columns-locked', String(lockColumns.value))
|
||||
}
|
||||
|
||||
// Computed for selected count
|
||||
const selectedCount = computed(() => {
|
||||
return Object.keys(rowSelection.value).length
|
||||
@@ -430,6 +434,9 @@ const loadShots = async () => {
|
||||
if (taskStatusFilter.value) {
|
||||
options.taskStatusFilter = taskStatusFilter.value
|
||||
}
|
||||
if (episodeFilter.value !== null) {
|
||||
options.episodeId = episodeFilter.value
|
||||
}
|
||||
|
||||
const data = await shotService.getShots(options)
|
||||
shots.value = data
|
||||
@@ -449,6 +456,11 @@ const loadEpisodes = async () => {
|
||||
episodes.value = data
|
||||
} catch (err) {
|
||||
console.error('Failed to load episodes:', err)
|
||||
toast({
|
||||
title: 'Failed to load episodes',
|
||||
description: err instanceof Error ? err.message : 'Episode filtering may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +472,11 @@ const loadTaskTypes = async () => {
|
||||
allTaskTypes.value = data.shot_task_types || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load task types:', err)
|
||||
toast({
|
||||
title: 'Failed to load task types',
|
||||
description: err instanceof Error ? err.message : 'Task columns may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +487,11 @@ const loadTaskStatuses = async () => {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load task statuses:', err)
|
||||
toast({
|
||||
title: 'Failed to load task statuses',
|
||||
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +503,11 @@ const loadProjectContext = async () => {
|
||||
projectContext.value = project
|
||||
} catch (err) {
|
||||
console.error('Failed to load project context:', err)
|
||||
toast({
|
||||
title: 'Failed to load project context',
|
||||
description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,6 +551,7 @@ const clearValidationError = () => {
|
||||
|
||||
const handleEpisodeFilterChange = (episodeId: number | null) => {
|
||||
episodeFilter.value = episodeId
|
||||
loadShots()
|
||||
}
|
||||
|
||||
const handleTaskStatusFilter = (filter: string) => {
|
||||
@@ -819,15 +847,22 @@ const clearSearch = () => {
|
||||
loadShots()
|
||||
}
|
||||
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this shot
|
||||
console.log('Create task for shot:', selectedShot.value?.name)
|
||||
const selectedTaskId = ref<number | null>(null)
|
||||
const selectedTaskTab = ref<string>('infos')
|
||||
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
|
||||
|
||||
const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
|
||||
selectedTaskId.value = task.id
|
||||
selectedTaskTab.value = tab || 'infos'
|
||||
selectedTaskReplyNoteId.value = noteId
|
||||
}
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Navigate to task detail view
|
||||
console.log('View task:', task.name)
|
||||
}
|
||||
// Reset the task sub-panel whenever the shot selection changes (including close)
|
||||
watch(selectedShot, () => {
|
||||
selectedTaskId.value = null
|
||||
selectedTaskTab.value = 'infos'
|
||||
selectedTaskReplyNoteId.value = undefined
|
||||
})
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Actions Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon-sm" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
<span class="sr-only">Shot actions</span>
|
||||
</Button>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click="toggleColumn('thumbnail')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('thumbnail')"
|
||||
@change="handleCheckboxChange('thumbnail', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click="toggleColumn('name')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('name')"
|
||||
@change="handleCheckboxChange('name', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Shot Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="episode" @click="toggleColumn('episode')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('episode')"
|
||||
@change="handleCheckboxChange('episode', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Episode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click="toggleColumn('status')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('status')"
|
||||
@change="handleCheckboxChange('status', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="taskType in allTaskTypes"
|
||||
:key="taskType"
|
||||
:value="taskType"
|
||||
@click="toggleColumn(taskType)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible(taskType)"
|
||||
@change="handleCheckboxChange(taskType, $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>{{ formatTaskType(taskType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Columns } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
}>()
|
||||
|
||||
// Use global store
|
||||
const columnVisibilityStore = useColumnVisibilityStore()
|
||||
|
||||
// Local reactive state for checkbox visibility
|
||||
const localVisibility = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for store changes and update local state
|
||||
watch(
|
||||
() => columnVisibilityStore.columnVisibility,
|
||||
(newVal) => {
|
||||
localVisibility.value = { ...newVal }
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// Initialize store on mount
|
||||
onMounted(() => {
|
||||
columnVisibilityStore.initialize()
|
||||
})
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
|
||||
const handleColumnToggle = (value: string) => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const isColumnVisible = (columnId: string): boolean => {
|
||||
// If not in visibility state, column is visible by default
|
||||
return localVisibility.value[columnId] !== false
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (column: string, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateColumn(column, target.checked)
|
||||
}
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
updateColumn(column, !isColumnVisible(column))
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean) => {
|
||||
// Always use global store
|
||||
columnVisibilityStore.updateColumn(column, checked)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -258,7 +258,7 @@ watch(() => props.open, (newOpen) => {
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
@@ -1,63 +1,60 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading shot details...</span>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-6 text-center">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load shot</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadShotDetails" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<DetailPanelError
|
||||
v-else-if="error"
|
||||
title="Failed to load shot"
|
||||
:message="error"
|
||||
@retry="loadShotDetails"
|
||||
/>
|
||||
|
||||
<!-- Shot Details -->
|
||||
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">{{ shot.name }}</h2>
|
||||
<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>
|
||||
<div v-else-if="shot" class="flex-1 flex flex-col min-h-0">
|
||||
<DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
||||
<template #badges>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.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) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DetailPanelHeader>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="assets">Assets</TabsTrigger>
|
||||
<TabsTrigger value="references">References</TabsTrigger>
|
||||
<TabsTrigger value="design">Design</TabsTrigger>
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
|
||||
<TabsTrigger value="infos" title="Infos">
|
||||
<Info class="h-4 w-4" />
|
||||
<span class="sr-only">Infos</span>
|
||||
</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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Shot Information</h3>
|
||||
@@ -131,15 +128,58 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<Button
|
||||
v-if="canCreateTask"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-task')"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Popover v-if="canCreateTask">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
|
||||
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
All task types already added
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="type in availableTaskTypes"
|
||||
:key="type"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
:disabled="isCreatingTask"
|
||||
@click="handleAddTask(type)"
|
||||
>
|
||||
{{ formatTaskType(type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon-sm" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for 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, 'submissions')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Tasks -->
|
||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||
@@ -147,57 +187,45 @@
|
||||
<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>
|
||||
</div>
|
||||
<!-- Tasks Table -->
|
||||
<div v-else class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
|
||||
<div>Task Type</div>
|
||||
<div>Assignee</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div
|
||||
<!-- Tasks Cards -->
|
||||
<div v-else class="space-y-1.5">
|
||||
<Card
|
||||
v-for="task in tasks"
|
||||
: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"
|
||||
@click="$emit('select-task', task)"
|
||||
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')"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ task.assigned_user_name || 'Unassigned' }}
|
||||
</div>
|
||||
<div>
|
||||
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
|
||||
{{ formatTaskStatus(task.status.toString()) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<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)" />
|
||||
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
|
||||
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
||||
<Button
|
||||
v-if="canCreateNote"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-note')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
</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>
|
||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
||||
Loading notes...
|
||||
</div>
|
||||
<EntityNotes
|
||||
v-else
|
||||
:key="shotId"
|
||||
:tasks="tasks"
|
||||
:notes="shotNotes"
|
||||
:submissions="shotSubmissions"
|
||||
@notes-updated="loadShotNotes"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="text-sm font-semibold">Linked Assets</h3>
|
||||
<Button
|
||||
@@ -219,18 +247,32 @@
|
||||
</TabsContent>
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||
<Button
|
||||
v-if="canUploadReferences"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('upload-reference')"
|
||||
>
|
||||
<Popover v-if="canUploadReferences">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference 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, 'attachments')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -241,7 +283,7 @@
|
||||
</TabsContent>
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="text-sm font-semibold">Design Information</h3>
|
||||
<Button
|
||||
@@ -280,37 +322,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
||||
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||
|
||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
interface Task extends TaskStatusInfo {
|
||||
id: number
|
||||
name?: string
|
||||
assigned_user_name?: string
|
||||
assigned_user_avatar_url?: string | null
|
||||
assigned_user_first_name?: string
|
||||
assigned_user_last_name?: string
|
||||
deadline?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
shotId: number
|
||||
initialShot?: Shot
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', shot: Shot): void
|
||||
(e: 'delete', shot: Shot): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
|
||||
(e: 'link-asset'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'edit-design'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
@@ -318,13 +375,21 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const shot = ref<Shot | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const shotNotes = ref<ProductionNote[]>([])
|
||||
const shotSubmissions = ref<Submission[]>([])
|
||||
const isLoadingNotes = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
@@ -362,24 +427,25 @@ const taskStatusCounts = computed(() => {
|
||||
|
||||
|
||||
|
||||
const canCreateTask = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canCreateNote = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const canLinkAssets = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canUploadReferences = computed(() => {
|
||||
return true // All users can upload references
|
||||
})
|
||||
|
||||
const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
// Task types offered in "Add Task" include the flat shot task type list
|
||||
// plus every task type owned by a shot department (e.g. Animation's own
|
||||
// task types), deduped against types the shot already has a task for.
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
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
|
||||
@@ -387,8 +453,14 @@ const loadShotDetails = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
shot.value = await shotService.getShot(props.shotId)
|
||||
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
|
||||
departmentsStore.fetchProjectDepartments(props.projectId)
|
||||
await Promise.all([
|
||||
taskStatusesStore.fetchProjectStatuses(props.projectId),
|
||||
loadProjectMembers()
|
||||
])
|
||||
loadTasks() // No longer async - uses embedded data
|
||||
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
||||
console.error('Failed to load shot details:', err)
|
||||
@@ -397,33 +469,76 @@ 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 () => {
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load project members:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = () => {
|
||||
// Use task_details already embedded in shot data - no API call needed!
|
||||
if (shot.value?.task_details) {
|
||||
tasks.value = shot.value.task_details.map(taskInfo => ({
|
||||
tasks.value = shot.value.task_details.map(taskInfo => {
|
||||
const assignedUser = projectMembers.value.find(member => member.user_id === taskInfo.assigned_user_id)
|
||||
return {
|
||||
id: taskInfo.task_id || 0,
|
||||
task_type: taskInfo.task_type,
|
||||
status: taskInfo.status,
|
||||
assigned_user_id: taskInfo.assigned_user_id,
|
||||
// Add placeholder values for display compatibility
|
||||
name: taskInfo.task_type, // Use task_type as name for display
|
||||
assigned_user_name: undefined // Will be resolved if needed
|
||||
}))
|
||||
assigned_user_name: assignedUser ? `${assignedUser.user_first_name} ${assignedUser.user_last_name}` : undefined,
|
||||
assigned_user_avatar_url: assignedUser?.user_avatar_url,
|
||||
assigned_user_first_name: assignedUser?.user_first_name,
|
||||
assigned_user_last_name: assignedUser?.user_last_name
|
||||
}
|
||||
})
|
||||
} else {
|
||||
tasks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
// Resolve the project's actual status object (name + color) for a task, matching the shot table
|
||||
const getTaskStatusObject = (task: Task) => {
|
||||
const statusId = task.status.toString()
|
||||
return taskStatusesStore.getStatusById(props.projectId, statusId) || statusId
|
||||
}
|
||||
|
||||
const formatTaskStatus = (status: string) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
const handleAddTask = async (taskType: string) => {
|
||||
isCreatingTask.value = true
|
||||
try {
|
||||
await taskService.createShotTask(props.shotId, taskType)
|
||||
// Bypass initialShot (which would just return the stale cached object) to get the new task
|
||||
shot.value = await shotService.getShot(props.shotId)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
isCreatingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
@@ -432,56 +547,10 @@ const formatTaskType = (taskType: string) => {
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
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 getTaskStatusVariant = (status: string | TaskStatus) => {
|
||||
const statusStr = status.toString()
|
||||
switch (statusStr) {
|
||||
case 'not_started':
|
||||
return 'secondary'
|
||||
case 'in_progress':
|
||||
return 'default'
|
||||
case 'submitted':
|
||||
return 'outline'
|
||||
case 'approved':
|
||||
return 'default'
|
||||
case 'retake':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
const getTaskAssigneeInitials = (task: Task) => {
|
||||
const first = task.assigned_user_first_name?.charAt(0) || ''
|
||||
const last = task.assigned_user_last_name?.charAt(0) || ''
|
||||
return (first + last).toUpperCase()
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -5,35 +5,11 @@
|
||||
<!-- Left Side - Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'grid' }"
|
||||
@click="$emit('update:view-mode', 'grid')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<LayoutGrid class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'list' }"
|
||||
@click="$emit('update:view-mode', 'list')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'table' }"
|
||||
@click="$emit('update:view-mode', 'table')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Table2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedToggle
|
||||
:options="viewModeOptions"
|
||||
:model-value="viewMode"
|
||||
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list' | 'table')"
|
||||
/>
|
||||
|
||||
<!-- Episode Filter -->
|
||||
<Popover v-if="episodes.length > 0">
|
||||
@@ -56,40 +32,22 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No episode found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
value="all"
|
||||
@select="$emit('update:episode-filter', null)"
|
||||
:model-value="episodeFilter === null"
|
||||
@update:model-value="$emit('update:episode-filter', null)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Episodes</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
</CheckableCommandItem>
|
||||
<CheckableCommandItem
|
||||
v-for="episode in episodes"
|
||||
:key="episode.id"
|
||||
:value="episode.id.toString()"
|
||||
@select="$emit('update:episode-filter', episode.id)"
|
||||
:model-value="episodeFilter === episode.id"
|
||||
@update:model-value="$emit('update:episode-filter', episode.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === episode.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ episode.name }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -97,7 +55,7 @@
|
||||
</Popover>
|
||||
|
||||
<!-- Task Status Filter (only for table view) -->
|
||||
<ShotTaskStatusFilter
|
||||
<TaskStatusFilter
|
||||
v-if="viewMode === 'table'"
|
||||
:all-task-types="allTaskTypes"
|
||||
:project-id="projectId"
|
||||
@@ -105,79 +63,12 @@
|
||||
/>
|
||||
|
||||
<!-- Column Visibility Control (only for table view) -->
|
||||
<Popover v-if="viewMode === 'table'">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'default'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
All Task Types
|
||||
</div>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'task'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColumnToggleList
|
||||
v-if="viewMode === 'table'"
|
||||
:columns="columnToggleOptions"
|
||||
:model-value="columnVisibility"
|
||||
@update:model-value="$emit('update:column-visibility', $event)"
|
||||
/>
|
||||
|
||||
<!-- Task Columns Toggle Button (only for table view) -->
|
||||
<Button
|
||||
@@ -193,32 +84,27 @@
|
||||
</Button>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
|
||||
<DetailPanelToggleButton
|
||||
v-if="viewMode === 'table'"
|
||||
:enabled="isDetailPanelEnabled"
|
||||
@toggle="$emit('toggle-detail-panel')"
|
||||
/>
|
||||
|
||||
<!-- Lock First Columns Toggle Button (only for table view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'table'"
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
@click="$emit('toggle-column-lock')"
|
||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||
size="icon-sm"
|
||||
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||
<Unlock v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Search and Actions -->
|
||||
@@ -235,10 +121,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
|
||||
<Button @click="$emit('bulk-create')" variant="outline" size="icon-sm">
|
||||
<Layers class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
|
||||
<Button @click="$emit('create-shot')" size="icon-sm" title="Create Shot">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -250,7 +136,7 @@
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
ListTodo, ListX, Lock, Unlock
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -265,11 +151,15 @@ import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CheckableCommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
|
||||
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
|
||||
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
|
||||
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
|
||||
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
|
||||
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
|
||||
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Episode } from '@/services/episode'
|
||||
import type { Shot } from '@/services/shot'
|
||||
@@ -284,6 +174,7 @@ interface Props {
|
||||
projectId: number
|
||||
selectedShot: Shot | null
|
||||
isDetailPanelEnabled: boolean
|
||||
isColumnsLocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -295,6 +186,7 @@ const emit = defineEmits<{
|
||||
'update:column-visibility': [value: VisibilityState]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'toggle-column-lock': []
|
||||
'toggle-task-columns': []
|
||||
'bulk-create': []
|
||||
'create-shot': []
|
||||
@@ -323,9 +215,20 @@ const hasFilters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
|
||||
const columnToggleOptions = computed(() =>
|
||||
allColumns.value.map(col => ({
|
||||
id: col.id,
|
||||
label: col.label,
|
||||
group: col.type === 'task' ? 'All Task Types' : undefined,
|
||||
}))
|
||||
)
|
||||
|
||||
const viewModeOptions = [
|
||||
{ value: 'grid', icon: LayoutGrid },
|
||||
{ value: 'list', icon: List },
|
||||
{ value: 'table', icon: Table2 },
|
||||
]
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
@@ -334,21 +237,9 @@ const allTaskColumnsVisible = computed(() => {
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||
|
||||
// Methods
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newVisibility = { ...props.columnVisibility }
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user