Compare commits

...

21 Commits

Author SHA1 Message Date
indigo 3d67c304a3 Merge department task types into the Task Type management page
Project Settings > Tasks previously only showed the flat custom task
type lists, with department-owned task types manageable only from the
separate Departments tab. The Asset/Shot Task Type sections now show a
merged view (flat + department-owned, tagged with their department),
and the Add/Edit/Delete dialog can create a task type directly under a
custom department instead of just as a general-purpose flat type.
2026-07-24 22:45:25 +08:00
indigo e1dd5c6eae Offer department-owned task types in the shot/asset Add Task dropdowns
ShotDetailPanel and AssetDetailPanel now merge department task types
(filtered to shot/asset departments respectively) into the flat task
type list shown in "Add Task", so types like Animation's blocking or
Composite's first_pass are selectable at creation time instead of only
via editing a task's type afterward. No backend change needed since
task creation already derives department from an owned task type.
2026-07-24 22:45:17 +08:00
indigo 31d17780a4 Add shot/asset typing and owned task types to departments
Departments now carry a type (shot or asset) and own a list of task
types (e.g. Animation: blocking/primary_pass/second_pass, Composite:
first_pass/second_pass, plus a new Simulation department), additive to
the existing flat Custom Task Type system. When a task's type belongs
to a department, its department is derived and kept in sync server-side
across create/update paths; Task Type is now editable in the Task
Detail panel and Department options are filtered to the task's
shot/asset scope.
2026-07-24 08:55:18 +08:00
indigo 9bbd3df53c Add assignee popover to Gantt chart status cells
The Gantt used a status-only EditableTaskStatus, unlike the shot/asset
data tables which pair status with an avatar-popover assignee picker.
Bring the Gantt's task-level EditableTaskStatus to parity (opt-in via a
showAssignee prop so the flat Tasks table's separate assignee column is
unaffected), and widen the frozen status column to fit both controls.
2026-07-22 04:03:42 +08:00
indigo c09710f4e5 Add per-project Department management
Departments are now a customizable per-project list (standard + custom),
matching the existing custom task type/status pattern, instead of a fixed
7-value enum. They're usable directly on tasks (new field, independent of
assignee) and continue to drive team member department roles.
2026-07-22 04:03:33 +08:00
indigo 11e1369f2f Add group-by toggle (Task Type / Shot) to the Schedule Gantt chart
Generalizes the previous task-type-only grouping into rowGroups, keyed
by either task_type or the shot/asset name depending on a new toolbar
toggle. Each row's label shows whichever dimension isn't already the
group header, so switching modes stays readable instead of showing
redundant text. Collapse state resets on mode/project change to avoid
stale group keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:34:19 +08:00
indigo 7f260067a2 Add editable status column and submission markers to the Gantt chart
Adds a second frozen "Task Status" column (EditableTaskStatus) next to
the task name, and small white dot markers on each bar showing
submission dates - backed by a new lightweight GET /tasks/submission-dates
endpoint (task_id + submitted_at only, to avoid an N+1 fetch across
potentially hundreds of scheduled tasks).

Restructures the chart's scroll handling from a single scrolling
container with sticky-positioned columns to two independently-scrolled
panes (frozen name/status columns, and the timeline), synced on
vertical scroll. This fixes several issues that came with the sticky
approach - transparency bleed-through on hover and on group-header
rows, row heights not matching between columns, and the horizontal
scrollbar spanning the frozen columns instead of just the timeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:20:28 +08:00
indigo 04c85be0f7 Add tab/project quick-switch dropdowns to the project breadcrumb
The project header breadcrumb now always shows the active tab
(previously Overview was hidden), and both the project-name and
tab-level crumbs become dropdowns: project name lists all projects
(with a checkmark on the active one, plus "All Projects"), and the
tab crumb lists Overview/Shots/Assets/Tasks/Schedule/Settings with a
checkmark on the current tab. The trailing ">" separator is skipped
after any crumb that renders as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:22:35 +08:00
indigo 74be250912 Generalize aggregated notes to a shared component, reuse in Asset panel
Moves shot/ShotNotes.vue to shared/EntityNotes.vue (it had no
shot-specific logic) and wires it into AssetDetailPanel.vue too,
replacing the old read-only AssetNotes.vue. Asset panel gains the same
multi-select task filter, sort, client-only/submission-notes toggles,
and bottom composer that Shot's panel already had, plus the same
min-h-0 layout fix needed for the pinned composer.

NoteItem's absolute-format timestamp now shows date-only (y/m/d) with
an info icon whose tooltip reveals the full y/m/d H:M:S - applied to
both note and submission-note entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:11:07 +08:00
indigo 172e05af3e Redesign shot detail panel notes: aggregated composer, filters, submission notes
Extracts a new ShotNotes.vue component (mirroring TaskNotes.vue) with a
pinned bottom composer, multi-select task filter, newest/oldest sort,
and toggleable client-only / submission-notes filters. Notes now merge
production notes with each task's submission notes into one
chronological, absolute-timestamped (y/m/d H:M:S) list.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:11:32 +08:00
indigo 9c5abf6342 Add example-data script for testing submissions and reviews
Idempotent script mirroring the existing note/schedule seed-data
scripts: seeds Submission + Review rows (with real placeholder files
on disk) for the same sample of Dragon Quest shots, so the
submissions/reviews UI has realistic data to test against.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 04:10:43 +08:00
indigo 981808b901 Extend submission config: .mxf/codec/frame-rate checks, regex naming, shot/asset tabs
- Movie spec checks now also cover codec (h264/h265/mjpeg/dnxhd/dnxhr/
  prores/uncompressed/avid/cineform) and frame rate (23.976/24/30/48/60),
  detected in-browser via mediainfo.js container parsing - works even
  for formats the native <video> element can't decode (MXF, ProRes,
  DNxHD). Added .mxf to the supported/allowed format lists.
- Naming pattern supports a raw regex mode (validated by compiling
  server-side at save time) as an alternative to the token pattern,
  and gained three more tokens: {task_name}, {project_name},
  {project_code} (fetched lazily only when referenced).
- Submission Configuration settings page now splits Shot Tasks /
  Asset Tasks into separate tabs instead of one merged list; the
  per-task-type card was extracted into SubmissionTypeConfigCard.vue
  to avoid duplicating it across both tabs.
2026-07-19 19:15:05 +08:00
indigo 23740af816 Add per-task-type submission configuration with client-side movie spec checks
Coordinators can configure, per task type, accepted file extensions, a
naming convention, and (for video files) required resolution/format/
codec/frame rate. All checking happens entirely client-side in the
browser before upload - the backend only stores and validates the
configuration itself, never inspects submitted files.

- backend: submission_config_by_task_type JSON column on Project,
  schemas/submission_config.py for validation, GET/PUT
  /projects/{id}/submission-config endpoints. Added .mxf/.ma/.usd(a/c)
  to the supported file formats.
- frontend: new Submissions tab in Project Settings
  (SubmissionConfigManager.vue) to configure rules per task type.
  TaskSubmissions.vue enforces them before upload: extension/naming
  checks via regex, and real video resolution/codec/frame rate
  detection via mediainfo.js (parses container metadata directly,
  so it works for formats browsers can't play natively like MXF,
  ProRes, or DNxHD).
2026-07-19 10:38:29 +08:00
indigo 762bd34f74 Redesign note display as message cards; fix reply and Textarea v-model bugs
- NoteItem.vue: render each note as a rounded card (own notes tinted)
  instead of a flat list row.
- TaskNotes.vue: show a "Replying to X" banner with cancel and
  auto-focus the composer when Reply is clicked, so replying is
  actually visible instead of silently setting hidden state.
- ShotDetailPanel/ShotBrowser/TaskDetailPanel: carry the target note
  id through select-task so replying from the shot-level aggregated
  notes view pre-fills the reply in the destination task's panel
  instead of just switching tabs.
- Textarea.vue: fix v-model, which was never wired up (modelValue/
  update:modelValue fell through as dead attrs on the native
  textarea), so typed content never reached the bound ref anywhere
  it's used - notes, submissions, and shot/asset/episode/project
  description fields.
- AssetDetailPanel/ShotDetailPanel/TaskDetailPanel: drop the header
  status badge and switch tab labels to icons (with an unread-style
  badge on Notes), fixing the TabsTrigger clipping/vertical alignment
  it introduced.
2026-07-19 00:00:11 +08:00
indigo 4bffec642e Align detail panel top edge with the header's actual bottom edge
DetailPanelOverlay.vue (shared by the shot/asset/task detail panels and
the My Tasks page) used a hardcoded top-[113px] that left a 49px gap
above the panel. The header is h-16 (64px), confirmed via its live
getBoundingClientRect() - switched to top-16 so the panel's top edge
sits flush against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 22:26:00 +08:00
indigo 548d1081ba Remove top project tabs in favor of the sidebar's Projects sub-menu
The sidebar already provides Overview/Shots/Assets/Tasks/Settings
navigation for the current project, making the top ProjectTabs bar
redundant. Removed it from ProjectDetailView.vue and deleted the
now-fully-unused ProjectTabs.vue component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 21:58:30 +08:00
indigo 960753b3d6 Make note/submission edit-own and edit-others' permissions explicit
Split note:edit/note:delete and submission:edit/submission:delete into
four independent permissions each - edit_self/delete_self (acting on
your own note or submission) and edit_other/delete_other (acting on
someone else's). Previously "own" access was an unconditional, unrevokable
ownership check with no permission behind it, and a prior round had
accidentally granted coordinator submission:edit/delete by default
(inconsistent with notes, which were correctly own-only) - both are fixed
here: self-service now goes through a real, default-granted-to-everyone
permission, and acting on someone else's note/submission is an explicit
elevated grant that nobody gets by default.

The Role Management permission editor now shows "Edit Own / Delete Own /
Edit Others' / Delete Others'" as four clear, independently toggleable
options instead of one ambiguous "Edit"/"Delete" checkbox.

migrate_role_permissions.py renames the existing permission rows in place
(rather than leaving orphaned duplicates) and includes a one-time,
idempotent correction that revokes the earlier over-grant from coordinator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 21:17:47 +08:00
indigo db2c414c1a Add multi-role permission system with a Role Management admin page
Users can now hold multiple roles, each with its own editable set of
create/edit/delete-style permissions across assets, shots, tasks, task
assignment, review approve/retake, submissions, uploads, and notes
(including internal vs. client note visibility). The 4 existing roles
(coordinator/director/artist/developer) are migrated into the new system
as system roles, seeded to reproduce today's actual behavior exactly;
admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the
new Role Management page and assign multiple roles to a user via a new
"Manage Roles" action on the Team page.

Backend:
- New Role/Permission models and role_permissions/user_roles tables,
  plus a one-off, idempotent seed/backfill migration script.
- New require_permission()/user_has_permission() dependency, wired into
  the actual mutation endpoints across shots/assets/tasks/reviews,
  always preserving existing ownership- and self-service-based access
  (e.g. artists editing their own task status, own notes, own uploads,
  own submissions) as an unconditional fallback alongside the new
  permission checks - nothing that worked before now requires a role.
- New endpoints: PUT/DELETE on task submissions (wires up soft-deletion
  columns that existed on the model but were never exposed), plus full
  role CRUD and per-user role assignment.
- Along the way: fixed newly-created users not being linked to their
  matching system role (silently leaving them with zero permissions),
  and unified an inconsistency between the single vs. bulk task status
  endpoints that allowed different roles to bulk-update status.

Frontend:
- Role Management page with a grouped, human-readable permission editor
  (icons, plain-language action labels, per-resource select-all, live
  selected count) replacing an earlier dense matrix prototype.
- hasPermission() added to the existing usePermission() composable
  without touching its current isAdmin/isCoordinatorOrAdmin consumers.
- Note composer gets an Internal/Client toggle; submissions gain inline
  edit/delete actions gated the same ownership-or-permission way as notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:41:17 +08:00
indigo 976ec40b52 Add per-project sub-navigation to the sidebar's Projects nav item
Inside a specific project, the "Projects" item in the sidebar now shows
Overview/Shots/Assets/Tasks/Settings as sub-links (mirroring the existing
top-of-page ProjectTabs), with the active tab highlighted to match the
current route.

When the sidebar is collapsed to icons, the Projects icon still links
straight to /projects like any other nav item; hovering it reveals the
same 5 sub-links as a flyout menu (a controlled Popover, not a
DropdownMenu, so its own click-to-open behavior doesn't hijack the
icon's normal navigation click).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 17:35:15 +08:00
indigo eb5587eb40 Consistent status colors, prevent native context menu leaks, fix settings 404
- Task status badges (bulk change-status popover, right-click menu) now
  use the real per-project TaskStatusesStore colors/labels instead of the
  hardcoded TaskStatus enum, matching EditableTaskStatus everywhere.
- Suppress the native browser context menu from leaking through gaps in
  the task table (header/empty space), open popovers/dropdowns and their
  submenus, the status Select, and a second right-click at the same
  coordinates (the popover's positioning anchor now has
  pointer-events: none so it doesn't intercept the repeat click).
- Fix 404s on /api/settings/* caused by a double-declared route prefix.
- Fix 422 on bulk-applying a custom task status by relaxing
  BulkStatusUpdate.status from the TaskStatus enum to str, matching the
  existing single-task update schema.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 16:07:39 +08:00
81 changed files with 7912 additions and 943 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
One-off script to generate example start_date/deadline data on shot tasks,
for testing/demoing the Project Schedule (Gantt) page.
Reuses the same shot-sampling approach as generate_note_example_data.py: the
dev database is heavily populated with disposable load-test fixtures, so this
picks a small, bounded sample of shots (from Dragon Quest) that have the
normal 4-task set, rather than scheduling all ~1400 shot tasks.
For each sampled shot, tasks are scheduled in pipeline order (layout ->
animation -> simulation -> lighting -> compositing, any other task types
appended after) with realistic overlapping durations, staggered around
today's date so the chart's "Today" marker falls inside the visible range.
Idempotent: skips any task that already has a start_date set, so it's safe
to re-run without clobbering manually-edited dates.
"""
from datetime import date, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.shot import Shot
from models.task import Task
from models.project import Project
import logging
import random
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PROJECT_NAME = "Dragon Quest"
SAMPLE_SIZE = 15
RNG_SEED = 42
# Canonical pipeline order used to sequence a shot's tasks; anything not
# listed here keeps its natural (query) order, appended at the end.
PIPELINE_ORDER = ["layout", "animation", "simulation", "lighting", "compositing", "previz"]
def pipeline_index(task_type: str) -> int:
return PIPELINE_ORDER.index(task_type) if task_type in PIPELINE_ORDER else len(PIPELINE_ORDER)
def generate_schedule_example_data():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
rng = random.Random(RNG_SEED)
today = date.today()
try:
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
if not project:
logger.error(f"Project '{PROJECT_NAME}' not found")
return
logger.info(f"Project '{PROJECT_NAME}' (id={project.id})")
# Shots with the normal 4-task set (excludes shots with zero tasks
# and the handful of outliers with extra bulk-test tasks piled on).
shots = db.query(Shot).filter(
Shot.project_id == project.id, Shot.deleted_at.is_(None)
).order_by(Shot.id).all()
candidates = [
s for s in shots
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
]
sample_shots = candidates[:SAMPLE_SIZE]
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
tasks_scheduled = 0
tasks_skipped = 0
for shot in sample_shots:
tasks = db.query(Task).filter(
Task.shot_id == shot.id, Task.deleted_at.is_(None)
).all()
tasks.sort(key=lambda t: pipeline_index(t.task_type))
# Stagger each shot's pipeline start across a window that straddles
# today, so the demo chart shows a mix of past/current/future work.
shot_start = today + timedelta(days=rng.randint(-20, 20))
cursor = shot_start
for task in tasks:
if task.start_date is not None:
tasks_skipped += 1
# Still advance the cursor so later tasks in this shot
# don't bunch up if an earlier one was already scheduled.
cursor = task.deadline or cursor
continue
duration = rng.randint(4, 12)
task.start_date = cursor
task.deadline = cursor + timedelta(days=duration)
tasks_scheduled += 1
# Next task starts a little before this one ends (realistic
# pipeline overlap) rather than strictly back-to-back.
cursor = task.deadline - timedelta(days=rng.randint(0, 4))
db.commit()
logger.info(f"Tasks scheduled: {tasks_scheduled}, skipped (already scheduled): {tasks_skipped}")
except Exception as e:
logger.error(f"Generation failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Generating schedule example data...")
generate_schedule_example_data()
logger.info("Done!")
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
One-off script to generate example Submission (and Review) data for
testing/demoing the work-submission and review flow.
Reuses the same shot-sampling approach as generate_note_example_data.py and
generate_schedule_example_data.py: picks the same bounded sample of Dragon
Quest shots (first 15 candidates with the normal 4-task set) rather than
touching all ~1400 shot tasks, so submissions line up with the tasks that
already have start_date/deadline from the schedule seed data.
Not every sampled task gets a submission (a "not started" task realistically
has none) - about 70% do, with 1-3 versions each. Earlier versions are given
a "retake" review, and the latest version is left pending, approved, or
retake'd, with the task's status updated to match. Each submission gets a
real placeholder file on disk under uploads/submissions/<task_id>/ (a tiny
real image for .jpg/.png so thumbnail generation works, plain bytes for
other formats) so the review UI has something to open instead of a 404.
Idempotent: skips any task that already has a submission, so it's safe to
re-run without creating duplicates.
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.shot import Shot
from models.task import Task, Submission, Review, ReviewDecision
from models.project import Project, ProjectMember
from models.user import User, UserRole
from utils.file_handler import file_handler
from PIL import Image
import io
import logging
import random
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PROJECT_NAME = "Dragon Quest"
SAMPLE_SIZE = 15 # same sample window as generate_schedule_example_data.py
RNG_SEED = 42
SUBMISSION_CHANCE = 0.7 # fraction of sampled tasks that get any submission
# Plausible deliverable formats per task type (all within FileHandler's
# SUPPORTED_FORMATS). Kept short - this is placeholder data, not a real
# pipeline convention.
TASK_TYPE_EXTENSIONS = {
"layout": [".ma", ".mov"],
"animation": [".mov", ".ma"],
"simulation": [".mov", ".ma"],
"lighting": [".exr", ".mov"],
"compositing": [".mov", ".png", ".exr"],
"previz": [".mov"],
}
DEFAULT_EXTENSIONS = [".mov"]
SUBMISSION_NOTES = [
"First pass, ready for review.",
"Addressed previous feedback, please take another look.",
"Still WIP on some details but wanted to get eyes on it early.",
"Final polish pass, should be good to approve.",
None, # some submissions have no note at all
]
RETAKE_FEEDBACK = [
"Good start, but needs another pass before this is ready - see notes on the task.",
"Close, but a few issues need fixing before this can move forward.",
"Not quite there yet, please revise and resubmit.",
]
APPROVE_FEEDBACK = [
"Looks great, approved!",
"Nice work, this is ready to move on.",
"Approved - matches the brief.",
]
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
def make_placeholder_file(task: Task, extension: str, version: int, rng: random.Random) -> tuple[str, int]:
"""Write a small real file to disk and return (relative_path, size)."""
task_dir = file_handler.create_directory_structure(task.id, "submission")
base_name = f"{(task.shot_id and 'shot' or 'asset')}_{task.task_type}_v{version:03d}{extension}"
filename = file_handler.generate_unique_filename(base_name, f"v{version:03d}")
file_path = task_dir / filename
if extension in IMAGE_EXTENSIONS:
# A tiny but real image so thumbnail generation (PIL.Image.open) works.
color = (rng.randint(30, 220), rng.randint(30, 220), rng.randint(30, 220))
img = Image.new("RGB", (64, 64), color)
buf = io.BytesIO()
img.save(buf, format="JPEG" if extension in (".jpg", ".jpeg") else "PNG")
file_path.write_bytes(buf.getvalue())
else:
file_path.write_bytes(f"Placeholder {extension} submission file for testing.\n".encode())
relative_path = file_handler.store_relative_path(str(file_path))
return relative_path, file_path.stat().st_size
def generate_submission_example_data():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
rng = random.Random(RNG_SEED)
now = datetime.now(timezone.utc)
try:
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
if not project:
logger.error(f"Project '{PROJECT_NAME}' not found")
return
member_ids = [m.user_id for m in db.query(ProjectMember).filter(
ProjectMember.project_id == project.id
).all()]
if not member_ids:
logger.error(f"No members found on project '{PROJECT_NAME}'")
return
reviewer_ids = [u.id for u in db.query(User).join(
ProjectMember, ProjectMember.user_id == User.id
).filter(
ProjectMember.project_id == project.id,
User.role.in_([UserRole.DIRECTOR, UserRole.COORDINATOR])
).all()] or member_ids
logger.info(f"Project '{PROJECT_NAME}' (id={project.id}), {len(member_ids)} members, {len(reviewer_ids)} potential reviewers")
# Shots with the normal 4-task set (excludes shots with zero tasks
# and the handful of outliers with extra bulk-test tasks piled on).
shots = db.query(Shot).filter(
Shot.project_id == project.id, Shot.deleted_at.is_(None)
).order_by(Shot.id).all()
candidates = [
s for s in shots
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
]
sample_shots = candidates[:SAMPLE_SIZE]
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
tasks_seen = 0
tasks_skipped = 0
tasks_submitted = 0
submissions_created = 0
reviews_created = 0
for shot in sample_shots:
tasks = db.query(Task).filter(
Task.shot_id == shot.id, Task.deleted_at.is_(None)
).all()
for task in tasks:
tasks_seen += 1
existing = db.query(Submission).filter(Submission.task_id == task.id).first()
if existing:
tasks_skipped += 1
continue
if rng.random() >= SUBMISSION_CHANCE:
continue
extensions = TASK_TYPE_EXTENSIONS.get(task.task_type, DEFAULT_EXTENSIONS)
version_count = rng.choice([1, 1, 2, 3])
submitter_id = task.assigned_user_id or rng.choice(member_ids)
# Spread submissions over the days leading up to the deadline
# (or today, if the task has none) so submitted_at reads as a
# real history rather than everything happening "now".
anchor = (
datetime.combine(task.deadline, datetime.min.time(), tzinfo=timezone.utc)
if task.deadline else now
)
submitted_at = anchor - timedelta(days=version_count * 2)
last_submission = None
last_decision = None
for version in range(1, version_count + 1):
extension = rng.choice(extensions)
relative_path, file_size = make_placeholder_file(task, extension, version, rng)
thumbnail_path = None
if extension in IMAGE_EXTENSIONS:
thumbnail_path = file_handler.create_thumbnail(relative_path)
submission = Submission(
task_id=task.id,
user_id=submitter_id,
file_path=relative_path,
file_name=Path(relative_path).name,
version_number=version,
notes=rng.choice(SUBMISSION_NOTES),
submitted_at=submitted_at
)
db.add(submission)
db.flush() # need submission.id for a Review FK
submissions_created += 1
submitted_at += timedelta(days=2)
is_last_version = version == version_count
if not is_last_version:
# Earlier versions were superseded because they needed work.
review = Review(
submission_id=submission.id,
reviewer_id=rng.choice(reviewer_ids),
decision=ReviewDecision.RETAKE,
feedback=rng.choice(RETAKE_FEEDBACK),
reviewed_at=submission.submitted_at + timedelta(hours=rng.randint(2, 20))
)
db.add(review)
reviews_created += 1
else:
# Latest version: leave some pending review, resolve the rest.
outcome = rng.choice(["pending", "approved", "approved", "retake"])
if outcome != "pending":
decision = ReviewDecision.APPROVED if outcome == "approved" else ReviewDecision.RETAKE
feedback = rng.choice(APPROVE_FEEDBACK if outcome == "approved" else RETAKE_FEEDBACK)
review = Review(
submission_id=submission.id,
reviewer_id=rng.choice(reviewer_ids),
decision=decision,
feedback=feedback,
reviewed_at=submission.submitted_at + timedelta(hours=rng.randint(2, 20))
)
db.add(review)
reviews_created += 1
last_decision = outcome
last_submission = submission
if last_submission:
task.status = {
"approved": "approved",
"retake": "retake",
"pending": "submitted"
}[last_decision]
tasks_submitted += 1
db.commit()
logger.info(f"Tasks seen: {tasks_seen}, skipped (already had a submission): {tasks_skipped}")
logger.info(f"Tasks given submissions: {tasks_submitted}")
logger.info(f"Submissions created: {submissions_created}, reviews created: {reviews_created}")
except Exception as e:
logger.error(f"Generation failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Generating submission example data...")
generate_submission_example_data()
logger.info("Done!")
+2 -1
View File
@@ -8,7 +8,7 @@ import json
import os import os
from database import engine, Base 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 to ensure they are registered with SQLAlchemy
import models import models
@@ -109,6 +109,7 @@ app.include_router(notifications.router, tags=["notifications"])
app.include_router(activities.router, tags=["activities"]) app.include_router(activities.router, tags=["activities"])
app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router(admin.router, prefix="/admin", tags=["admin"])
app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"]) app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"])
app.include_router(roles.router, prefix="/roles", tags=["roles"])
@app.get("/") @app.get("/")
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Migration script to normalize projects.custom_departments from a flat list of
strings (pre-department-type feature) to a list of objects:
{"name": str, "type": "shot"|"asset", "task_types": [str]}.
Usage:
python migrate_department_task_types.py
"""
import sqlite3
import sys
import json
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def migrate_database():
"""Normalize any legacy plain-string custom_departments entries."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("Projects table not found. Nothing to migrate.")
conn.close()
return
cursor.execute("SELECT id, custom_departments FROM projects")
rows = cursor.fetchall()
normalized_count = 0
for project_id, custom_departments_raw in rows:
if not custom_departments_raw:
continue
try:
custom_departments = json.loads(custom_departments_raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(custom_departments, list) or not custom_departments:
continue
needs_normalization = any(isinstance(d, str) for d in custom_departments)
if not needs_normalization:
continue
normalized = []
for d in custom_departments:
if isinstance(d, str):
print(f" Project {project_id}: normalizing legacy department '{d}' "
f"(defaulting type='shot', task_types=['{d}'] - review if incorrect)")
normalized.append({"name": d, "type": "shot", "task_types": [d]})
else:
normalized.append(d)
cursor.execute(
"UPDATE projects SET custom_departments = ? WHERE id = ?",
(json.dumps(normalized), project_id)
)
normalized_count += 1
conn.commit()
print(f"Migration completed successfully! {normalized_count} project(s) normalized.")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Department Task Types Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Migration script to add the custom_departments column to the projects table.
Usage:
python migrate_project_custom_departments.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add custom_departments column to the projects table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
if not cursor.fetchone():
print("Projects table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "projects", "custom_departments"):
print("Column custom_departments already exists, skipping...")
else:
print("Adding column: custom_departments")
cursor.execute("ALTER TABLE projects ADD COLUMN custom_departments TEXT")
cursor.execute("UPDATE projects SET custom_departments = '[]' WHERE custom_departments IS NULL")
# project_members.department_role was previously backed by SQLAlchemy's
# Enum(DepartmentRole) type, which stores the enum MEMBER NAME (e.g. "LAYOUT"),
# not its value ("layout"). The old Optional[DepartmentRole] schema silently
# normalized this back to lowercase on read. Now that the column is a plain
# string, normalize any existing uppercase values so they match the standard
# department strings used everywhere else.
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'")
if cursor.fetchone():
department_name_to_value = {
'LAYOUT': 'layout',
'ANIMATION': 'animation',
'LIGHTING': 'lighting',
'COMPOSITE': 'composite',
'MODELING': 'modeling',
'RIGGING': 'rigging',
'SURFACING': 'surfacing',
}
for old_value, new_value in department_name_to_value.items():
cursor.execute(
"UPDATE project_members SET department_role = ? WHERE department_role = ?",
(new_value, old_value)
)
if cursor.rowcount > 0:
print(f"Normalized {cursor.rowcount} project members from '{old_value}' to '{new_value}'")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM projects")
project_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to '[]').")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Project Custom Departments Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
@@ -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!")
+254
View File
@@ -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!")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Migration script to add the department column to the tasks table.
Usage:
python migrate_task_department.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add department column to the tasks table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
if not cursor.fetchone():
print("Tasks table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "tasks", "department"):
print("Column department already exists, skipping...")
else:
print("Adding column: department")
cursor.execute("ALTER TABLE tasks ADD COLUMN department VARCHAR")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM tasks")
task_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Task Department Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Migration script to add the start_date column to the tasks table.
Usage:
python migrate_task_start_date.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add start_date column to the tasks table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
if not cursor.fetchone():
print("Tasks table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "tasks", "start_date"):
print("Column start_date already exists, skipping...")
else:
print("Adding column: start_date")
cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM tasks")
task_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Task Start Date Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+8 -5
View File
@@ -1,22 +1,23 @@
# Models package # Models package
from .user import User, UserRole, DepartmentRole from .user import User, UserRole
from .project import Project, ProjectMember, ProjectStatus from .project import Project, ProjectMember, ProjectStatus
from .episode import Episode, EpisodeStatus from .episode import Episode, EpisodeStatus
from .asset import Asset, AssetCategory, AssetStatus from .asset import Asset, AssetCategory, AssetStatus
from .shot import Shot, ShotStatus from .shot import Shot, ShotStatus
from .task import ( from .task import (
Task, Submission, Review, ProductionNote, TaskAttachment, Task, Submission, Review, ProductionNote, TaskAttachment,
TaskType, TaskStatus, ReviewDecision, AttachmentType TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
) )
from .api_key import APIKey, APIKeyScope from .api_key import APIKey, APIKeyScope
from .api_key_usage import APIKeyUsage from .api_key_usage import APIKeyUsage
from .global_settings import GlobalSettings from .global_settings import GlobalSettings
from .notification import Notification, UserNotificationPreference, NotificationType from .notification import Notification, UserNotificationPreference, NotificationType
from .activity import Activity, ActivityType from .activity import Activity, ActivityType
from .role import Role, Permission, role_permissions, user_roles
__all__ = [ __all__ = [
# User models # User models
"User", "UserRole", "DepartmentRole", "User", "UserRole",
# Project models # Project models
"Project", "ProjectMember", "ProjectStatus", "Project", "ProjectMember", "ProjectStatus",
# Episode models # Episode models
@@ -27,7 +28,7 @@ __all__ = [
"Shot", "ShotStatus", "Shot", "ShotStatus",
# Task models # Task models
"Task", "Submission", "Review", "ProductionNote", "TaskAttachment", "Task", "Submission", "Review", "ProductionNote", "TaskAttachment",
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "NoteType",
# API Key models # API Key models
"APIKey", "APIKeyScope", "APIKeyUsage", "APIKey", "APIKeyScope", "APIKeyUsage",
# Global Settings models # Global Settings models
@@ -35,5 +36,7 @@ __all__ = [
# Notification models # Notification models
"Notification", "UserNotificationPreference", "NotificationType", "Notification", "UserNotificationPreference", "NotificationType",
# Activity models # Activity models
"Activity", "ActivityType" "Activity", "ActivityType",
# Role models
"Role", "Permission", "role_permissions", "user_roles"
] ]
+7 -2
View File
@@ -2,7 +2,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from database import Base from database import Base
from .user import DepartmentRole
import enum import enum
@@ -54,6 +53,12 @@ class Project(Base):
# Custom task statuses # Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
# Custom departments
custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
# Submission configuration per task type
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
# Project thumbnail # Project thumbnail
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
@@ -77,7 +82,7 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
department_role = Column(Enum(DepartmentRole), nullable=True) department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
joined_at = Column(DateTime(timezone=True), server_default=func.now()) joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
+56
View File
@@ -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})>"
+8
View File
@@ -38,6 +38,11 @@ class AttachmentType(str, enum.Enum):
DOCUMENTATION = "documentation" DOCUMENTATION = "documentation"
class NoteType(str, enum.Enum):
INTERNAL = "internal"
CLIENT = "client"
class Task(Base): class Task(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
@@ -51,6 +56,8 @@ class Task(Base):
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
description = Column(Text) description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
start_date = Column(Date)
deadline = Column(Date) deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -190,6 +197,7 @@ class ProductionNote(Base):
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(Text, 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) parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+1 -10
View File
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
DEVELOPER = "developer" DEVELOPER = "developer"
class DepartmentRole(str, enum.Enum):
LAYOUT = "layout"
ANIMATION = "animation"
LIGHTING = "lighting"
COMPOSITE = "composite"
MODELING = "modeling"
RIGGING = "rigging"
SURFACING = "surfacing"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -47,6 +37,7 @@ class User(Base):
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan") api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
notifications = relationship("Notification", 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") 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): def __repr__(self):
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>" return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
+16 -28
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict from typing import List, Dict, Optional
from database import get_db from database import get_db
from models.asset import Asset, AssetCategory from models.asset import Asset, AssetCategory
@@ -9,7 +9,8 @@ from models.task import Task, TaskType, TaskStatus
from models.user import User, UserRole from models.user import User, UserRole
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
from schemas.task import TaskCreate from schemas.task import TaskCreate
from utils.auth import get_current_user_from_token from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.asset_soft_deletion import AssetSoftDeletionService from services.asset_soft_deletion import AssetSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -24,22 +25,6 @@ def get_current_user_with_db(
return _get_user_from_db(db, token_data["user_id"]) 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: def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
"""Get sort order for task status, including custom statuses.""" """Get sort order for task status, including custom statuses."""
# Default system status order # 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 return STANDARD_ASSET_TASK_TYPES + custom_types
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]: def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
"""Create default tasks for an asset.""" """Create default tasks for an asset."""
created_tasks = [] created_tasks = []
@@ -150,7 +135,8 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"Default {task_type} task for {asset.name}", description=f"Default {task_type} task for {asset.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type) if project else None
) )
db.add(db_task) db.add(db_task)
@@ -374,11 +360,11 @@ async def create_asset(
asset: AssetCreate, asset: AssetCreate,
project_id: int, project_id: int,
db: Session = Depends(get_db), 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""" """Create a new asset in a project with optional default tasks"""
# Check project access # Check project access
check_project_access(project_id, current_user, db) project = check_project_access(project_id, current_user, db)
# Check if asset name already exists in project (exclude soft deleted) # Check if asset name already exists in project (exclude soft deleted)
existing_asset = db.query(Asset).filter( existing_asset = db.query(Asset).filter(
@@ -424,7 +410,7 @@ async def create_asset(
task_types = get_default_asset_task_types(asset.category) task_types = get_default_asset_task_types(asset.category)
# Create the tasks # Create the tasks
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db) created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
task_count = len(created_tasks) task_count = len(created_tasks)
db.commit() db.commit()
@@ -536,7 +522,7 @@ async def create_asset_task(
asset_id: int, asset_id: int,
task_type: str, # Changed from TaskType enum to str task_type: str, # Changed from TaskType enum to str
db: Session = Depends(get_db), 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""" """Create a new task for an asset"""
# Exclude soft deleted assets # Exclude soft deleted assets
@@ -569,13 +555,15 @@ async def create_asset_task(
# Create the task # Create the task
task_name = f"{asset.name} - {task_type.title()}" task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task( db_task = Task(
project_id=asset.project_id, project_id=asset.project_id,
asset_id=asset.id, asset_id=asset.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {asset.name}", description=f"{task_type.title()} task for {asset.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type)
) )
db.add(db_task) db.add(db_task)
@@ -595,7 +583,7 @@ async def update_asset(
asset_id: int, asset_id: int,
asset_update: AssetUpdate, asset_update: AssetUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin) current_user: User = Depends(require_permission('asset', 'edit'))
): ):
"""Update an asset""" """Update an asset"""
# Exclude soft deleted assets # Exclude soft deleted assets
@@ -651,7 +639,7 @@ async def update_asset(
async def get_asset_deletion_info( async def get_asset_deletion_info(
asset_id: int, asset_id: int,
db: Session = Depends(get_db), 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""" """Get information about what will be deleted when deleting an asset"""
# Exclude soft deleted assets # Exclude soft deleted assets
@@ -701,7 +689,7 @@ async def get_asset_deletion_info(
async def delete_asset( async def delete_asset(
asset_id: int, asset_id: int,
db: Session = Depends(get_db), 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""" """Soft delete an asset and all its associated data"""
# Exclude soft deleted assets # Exclude soft deleted assets
+4
View File
@@ -55,6 +55,10 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)):
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
from utils.auth import link_system_role
link_system_role(new_user, db)
db.commit()
return { return {
"message": "User registered successfully. Awaiting admin approval.", "message": "User registered successfully. Awaiting admin approval.",
"user_id": new_user.id "user_id": new_user.id
+597
View File
@@ -16,6 +16,7 @@ from schemas.project import (
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS, ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
) )
from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
from utils.auth import get_current_user, require_role, get_current_user_from_token from utils.auth import get_current_user, require_role, get_current_user_from_token
router = APIRouter() router = APIRouter()
@@ -1077,6 +1078,602 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project) return _build_all_task_types_response(db_project)
# Department Management Endpoints
# Standard departments (read-only): name, whether they apply to shots or assets,
# and the task types they own.
STANDARD_DEPARTMENTS = [
{"name": "layout", "type": "shot", "task_types": ["layout"]},
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
]
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
def _find_custom_department(custom_departments: list, name: str):
"""Find a custom department dict by name, or None."""
for department in custom_departments:
if department["name"] == name:
return department
return None
def _build_all_departments_response(db_project: Project):
"""Helper function to build AllDepartmentsResponse"""
from schemas.department import AllDepartmentsResponse, DepartmentInfo
custom_departments = db_project.custom_departments or []
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
return AllDepartmentsResponse(
departments=standard_infos + custom_infos,
standard_departments=standard_infos,
custom_departments=custom_infos
)
@router.get("/{project_id}/departments")
async def get_all_departments(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_with_db)
):
"""Get all departments (standard + custom) for a project"""
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
async def add_department(
project_id: int,
department_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a new custom department to a project"""
from schemas.department import CustomDepartmentCreate
try:
department_create = CustomDepartmentCreate(**department_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if department_create.department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
)
if _find_custom_department(custom_departments, department_create.department):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' already exists"
)
custom_departments.append({
"name": department_create.department,
"type": department_create.department_type,
"task_types": department_create.task_types
})
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}")
async def update_department(
project_id: int,
department: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Update a custom department name, cascading the rename to members and tasks using it"""
from schemas.department import CustomDepartmentUpdate
from models.task import Task
try:
department_update = CustomDepartmentUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if department != department_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Department in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department_update.old_name)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department_update.old_name}' not found"
)
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' is a standard department"
)
if (department_update.new_name != department_update.old_name
and _find_custom_department(custom_departments, department_update.new_name)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' already exists"
)
existing["name"] = department_update.new_name
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to project members using this department
members_to_update = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department_update.old_name
).all()
for member in members_to_update:
member.department_role = department_update.new_name
# Cascade rename to tasks using this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department_update.old_name
).all()
for task in tasks_to_update:
task.department = department_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update department"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}")
async def delete_department(
project_id: int,
department: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a custom department (blocked if any member or task is currently using it)"""
from models.task import Task
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments cannot be deleted"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if not _find_custom_department(custom_departments, department):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
members_using_department = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department
).all()
tasks_using_department = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department
).all()
if members_using_department or tasks_using_department:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete department '{department}' because it is currently in use",
"department": department,
"member_count": len(members_using_department),
"task_count": len(tasks_using_department)
}
)
custom_departments = [d for d in custom_departments if d["name"] != department]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
async def add_department_task_type(
project_id: int,
department: str,
task_type_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a task type to a custom department"""
from schemas.department import DepartmentTaskTypeCreate
try:
task_type_create = DepartmentTaskTypeCreate(**task_type_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_create.task_type in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
)
existing["task_types"].append(task_type_create.task_type)
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department task type"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
async def rename_department_task_type(
project_id: int,
department: str,
task_type: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
from schemas.department import DepartmentTaskTypeUpdate
from models.task import Task
try:
task_type_update = DepartmentTaskTypeUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if task_type != task_type_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Task type in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_update.old_name not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
)
if (task_type_update.new_name != task_type_update.old_name
and task_type_update.new_name in existing["task_types"]):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
)
existing["task_types"] = [
task_type_update.new_name if t == task_type_update.old_name else t
for t in existing["task_types"]
]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to tasks using this task type within this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type_update.old_name
).all()
for task in tasks_to_update:
task.task_type = task_type_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to rename department task type"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
async def delete_department_task_type(
project_id: int,
department: str,
task_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
from models.task import Task
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type}' not found in department '{department}'"
)
tasks_using_task_type = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type
).all()
if tasks_using_task_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
"department": department,
"task_type": task_type,
"task_count": len(tasks_using_task_type)
}
)
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department task type"
)
return _build_all_departments_response(db_project)
# Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
async def get_project_submission_config(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_with_db)
):
"""Get the project's per-task-type submission configuration"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if current_user.role == UserRole.ARTIST:
member = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
).first()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this project"
)
config_data = project.submission_config_by_task_type
if isinstance(config_data, str):
try:
config_data = json.loads(config_data)
except (json.JSONDecodeError, TypeError):
config_data = {}
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
async def update_project_submission_config(
project_id: int,
submission_config: ProjectSubmissionConfig,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Replace the project's per-task-type submission configuration"""
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
all_types_response = _build_all_task_types_response(db_project)
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
for task_type in submission_config.config_by_task_type.keys():
if task_type not in valid_task_types:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"'{task_type}' is not a valid task type for this project"
)
db_project.submission_config_by_task_type = {
task_type: config.dict()
for task_type, config in submission_config.config_by_task_type.items()
}
try:
db.commit()
db.refresh(db_project)
except Exception:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update submission configuration"
)
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
# Project Thumbnail Management Endpoints # Project Thumbnail Management Endpoints
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED) @router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
+5 -5
View File
@@ -7,7 +7,7 @@ from database import get_db
from models.task import Task, Submission, Review, TaskStatus from models.task import Task, Submission, Review, TaskStatus
from models.user import User, UserRole from models.user import User, UserRole
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse 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 from utils.notifications import notification_service
router = APIRouter() router = APIRouter()
@@ -126,9 +126,9 @@ async def approve_submission(
submission_id: int, submission_id: int,
review: ReviewCreate, review: ReviewCreate,
db: Session = Depends(get_db), 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( submission = db.query(Submission).options(
joinedload(Submission.task) joinedload(Submission.task)
@@ -189,9 +189,9 @@ async def request_retake(
submission_id: int, submission_id: int,
review: ReviewCreate, review: ReviewCreate,
db: Session = Depends(get_db), 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( submission = db.query(Submission).options(
joinedload(Submission.task) joinedload(Submission.task)
+164
View File
@@ -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"}
+1 -1
View File
@@ -14,7 +14,7 @@ from schemas.global_settings import (
from utils.auth import get_current_user, require_admin_permission from utils.auth import get_current_user, require_admin_permission
from models.user import User from models.user import User
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(tags=["settings"])
# Default upload limit in MB (1GB) # Default upload limit in MB (1GB)
DEFAULT_UPLOAD_LIMIT_MB = 1000 DEFAULT_UPLOAD_LIMIT_MB = 1000
+18 -28
View File
@@ -12,7 +12,8 @@ from schemas.shot import (
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse, ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
BulkShotCreate, BulkShotResponse, TaskStatusInfo 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 from services.shot_soft_deletion import ShotSoftDeletionService
router = APIRouter() router = APIRouter()
@@ -27,22 +28,6 @@ def get_current_user_with_db(
return _get_user_from_db(db, token_data["user_id"]) 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): def check_episode_access(episode_id: int, current_user: User, db: Session):
"""Check if user has access to the episode and its project.""" """Check if user has access to the episode and its project."""
# Debug logging # 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 return STANDARD_SHOT_TASK_TYPES + custom_types
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session): def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
"""Create default tasks for a shot.""" """Create default tasks for a shot."""
created_tasks = [] created_tasks = []
@@ -161,7 +146,8 @@ def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session
shot_id=shot.id, shot_id=shot.id,
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=task_description description=task_description,
department=find_owning_department(project, task_type) if project else None
) )
db.add(task) db.add(task)
@@ -381,7 +367,7 @@ async def create_shot(
episode_id: int, episode_id: int,
create_default_tasks: bool = True, create_default_tasks: bool = True,
db: Session = Depends(get_db), 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""" """Create a new shot in an episode"""
# Check episode access # Check episode access
@@ -436,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db) all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings) # Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types() default_task_types = get_default_shot_task_types()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db) project = db.query(Project).filter(Project.id == episode.project_id).first()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
db.commit() db.commit()
task_count = len(created_tasks) task_count = len(created_tasks)
@@ -452,7 +439,7 @@ async def create_shots_bulk(
bulk_shot: BulkShotCreate, bulk_shot: BulkShotCreate,
episode_id: int, episode_id: int,
db: Session = Depends(get_db), 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""" """Create multiple shots with naming pattern and default tasks"""
# Check episode access # Check episode access
@@ -514,6 +501,7 @@ async def create_shots_bulk(
created_shots = [] created_shots = []
total_tasks_created = 0 total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try: try:
# Create all shots - validation already done above # Create all shots - validation already done above
@@ -541,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested # Create default tasks if requested
task_count = 0 task_count = 0
if bulk_shot.create_default_tasks: if bulk_shot.create_default_tasks:
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db) created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
task_count = len(created_tasks) task_count = len(created_tasks)
total_tasks_created += task_count total_tasks_created += task_count
@@ -674,7 +662,7 @@ async def create_shot_task(
shot_id: int, shot_id: int,
task_type: str, task_type: str,
db: Session = Depends(get_db), 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""" """Create a new task for a shot"""
# Exclude soft deleted shots # Exclude soft deleted shots
@@ -710,6 +698,7 @@ async def create_shot_task(
# Create the task # Create the task
task_name = f"{shot.name} - {task_type.title()}" task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task( db_task = Task(
project_id=shot.project_id, project_id=shot.project_id,
episode_id=shot.episode_id, episode_id=shot.episode_id,
@@ -717,7 +706,8 @@ async def create_shot_task(
task_type=task_type, task_type=task_type,
name=task_name, name=task_name,
description=f"{task_type.title()} task for {shot.name}", description=f"{task_type.title()} task for {shot.name}",
status="not_started" status="not_started",
department=find_owning_department(project, task_type)
) )
db.add(db_task) db.add(db_task)
@@ -737,7 +727,7 @@ async def update_shot(
shot_id: int, shot_id: int,
shot_update: ShotUpdate, shot_update: ShotUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin) current_user: User = Depends(require_permission('shot', 'edit'))
): ):
"""Update a shot""" """Update a shot"""
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -818,7 +808,7 @@ async def update_shot(
async def get_shot_deletion_info( async def get_shot_deletion_info(
shot_id: int, shot_id: int,
db: Session = Depends(get_db), 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""" """Get information about what will be deleted when deleting a shot"""
# Exclude soft deleted shots # Exclude soft deleted shots
@@ -868,7 +858,7 @@ async def get_shot_deletion_info(
async def delete_shot( async def delete_shot(
shot_id: int, shot_id: int,
db: Session = Depends(get_db), 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""" """Soft delete a shot and all its associated data"""
# Exclude soft deleted shots # Exclude soft deleted shots
+210 -51
View File
@@ -8,8 +8,8 @@ import json
from datetime import datetime from datetime import datetime
from database import get_db from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
from models.user import User, UserRole, DepartmentRole from models.user import User, UserRole
from models.project import Project, ProjectMember from models.project import Project, ProjectMember
from models.asset import Asset from models.asset import Asset
from models.shot import Shot from models.shot import Shot
@@ -19,12 +19,13 @@ from schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment, TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse, ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
TaskAttachmentCreate, TaskAttachmentResponse, TaskAttachmentCreate, TaskAttachmentResponse,
SubmissionCreate, SubmissionResponse, SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
BulkStatusUpdate, BulkAssignment, BulkActionResult BulkStatusUpdate, BulkAssignment, BulkActionResult
) )
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
from utils.notifications import notification_service from utils.notifications import notification_service
from utils.file_handler import file_handler from utils.file_handler import file_handler
from utils.departments import find_owning_department
router = APIRouter() router = APIRouter()
@@ -93,20 +94,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo
return False 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( def get_current_user(
token_data: dict = Depends(get_current_user_from_token), token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -262,6 +249,8 @@ async def get_tasks(
"name": task.name, "name": task.name,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"project_name": task.project.name if task.project else None, "project_name": task.project.name if task.project else None,
@@ -343,6 +332,8 @@ async def get_my_tasks(
name=task.name, name=task.name,
task_type=task.task_type, task_type=task.task_type,
status=task.status, status=task.status,
department=task.department,
start_date=task.start_date,
deadline=task.deadline, deadline=task.deadline,
project_id=task.project_id, project_id=task.project_id,
project_name=task.project.name if task.project else "Unknown", project_name=task.project.name if task.project else "Unknown",
@@ -372,7 +363,7 @@ async def get_my_tasks(
async def create_task( async def create_task(
task: TaskCreate, task: TaskCreate,
db: Session = Depends(get_db), 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.""" """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" detail=f"Invalid status '{task_data['status']}' for this project"
) )
# If the task type belongs to a department, that department wins over
# anything explicitly submitted for `department`.
owning_department = find_owning_department(project, task_data.get('task_type'))
if owning_department:
task_data['department'] = owning_department
# Create task # Create task
db_task = Task(**task_data) db_task = Task(**task_data)
db.add(db_task) db.add(db_task)
@@ -520,7 +517,7 @@ async def bulk_update_task_status(
}) })
failed_count += 1 failed_count += 1
continue 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({ errors.append({
"task_id": task_id, "task_id": task_id,
"error": "Insufficient permissions" "error": "Insufficient permissions"
@@ -597,7 +594,7 @@ async def bulk_update_task_status(
async def bulk_assign_tasks( async def bulk_assign_tasks(
bulk_assignment: BulkAssignment, bulk_assignment: BulkAssignment,
db: Session = Depends(get_db), 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. 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) @router.get("/{task_id}", response_model=TaskResponse)
async def get_task( async def get_task(
task_id: int, task_id: int,
@@ -732,6 +749,8 @@ async def get_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -780,7 +799,7 @@ async def update_task(
# Artists can only update status # Artists can only update status
if task_update.model_dump(exclude_unset=True).keys() - {"status"}: if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
raise HTTPException(status_code=403, detail="Artists can only update task 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") raise HTTPException(status_code=403, detail="Not authorized to update tasks")
# Verify assigned user if being updated # Verify assigned user if being updated
@@ -790,6 +809,10 @@ async def update_task(
if not assigned_user: if not assigned_user:
raise HTTPException(status_code=404, detail="Assigned user not found") 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 # Check if user is a project member
project_member = db.query(ProjectMember).filter( project_member = db.query(ProjectMember).filter(
and_( and_(
@@ -800,6 +823,8 @@ async def update_task(
if not project_member: if not project_member:
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project") raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
else: 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 task_update.assigned_user_id = None
# Validate status if being updated # Validate status if being updated
@@ -812,6 +837,14 @@ async def update_task(
detail=f"Invalid status '{update_data['status']}' for this project" detail=f"Invalid status '{update_data['status']}' for this project"
) )
# If the task type is being changed to one owned by a department, that
# department wins over anything explicitly submitted for `department`.
if 'task_type' in update_data:
project = db.query(Project).filter(Project.id == task.project_id).first()
owning_department = find_owning_department(project, update_data['task_type'])
if owning_department:
update_data['department'] = owning_department
# Update task # Update task
for field, value in update_data.items(): for field, value in update_data.items():
setattr(task, field, value) setattr(task, field, value)
@@ -851,6 +884,8 @@ async def update_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -893,9 +928,10 @@ async def update_task_status(
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
# Permission check # Permission check
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: if current_user.role == UserRole.ARTIST:
raise HTTPException(status_code=403, detail="Not authorized to update this task") if task.assigned_user_id != current_user.id:
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin: raise HTTPException(status_code=403, detail="Not authorized to update this task")
elif not user_has_permission(current_user, 'task', 'change_status', db):
raise HTTPException(status_code=403, detail="Not authorized to update task status") raise HTTPException(status_code=403, detail="Not authorized to update task status")
# Validate the status for the task's project # Validate the status for the task's project
@@ -941,6 +977,8 @@ async def update_task_status(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -966,7 +1004,7 @@ async def assign_task(
task_id: int, task_id: int,
assignment: TaskAssignment, assignment: TaskAssignment,
db: Session = Depends(get_db), 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.""" """Assign a task to a user with department role filtering."""
@@ -982,6 +1020,12 @@ async def assign_task(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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 # Verify assigned user exists and is a project member
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first() assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
if not assigned_user: if not assigned_user:
@@ -1000,13 +1044,13 @@ async def assign_task(
# Check if user's department role matches task type (optional validation) # Check if user's department role matches task type (optional validation)
task_to_department_mapping = { task_to_department_mapping = {
"layout": DepartmentRole.LAYOUT, "layout": "layout",
"animation": DepartmentRole.ANIMATION, "animation": "animation",
"lighting": DepartmentRole.LIGHTING, "lighting": "lighting",
"compositing": DepartmentRole.COMPOSITE, "compositing": "composite",
"modeling": DepartmentRole.MODELING, "modeling": "modeling",
"rigging": DepartmentRole.RIGGING, "rigging": "rigging",
"surfacing": DepartmentRole.SURFACING, "surfacing": "surfacing",
"simulation": None # Simulation can be handled by multiple departments "simulation": None # Simulation can be handled by multiple departments
} }
@@ -1040,6 +1084,8 @@ async def assign_task(
"description": task.description, "description": task.description,
"task_type": task.task_type, "task_type": task.task_type,
"status": task.status, "status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline, "deadline": task.deadline,
"project_id": task.project_id, "project_id": task.project_id,
"episode_id": task.episode_id, "episode_id": task.episode_id,
@@ -1064,7 +1110,7 @@ async def assign_task(
async def delete_task( async def delete_task(
task_id: int, task_id: int,
db: Session = Depends(get_db), 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.""" """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) ProductionNote.deleted_at.is_(None)
).order_by(ProductionNote.created_at).all() ).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 # Build threaded structure
notes_dict = {} notes_dict = {}
root_notes = [] root_notes = []
@@ -1127,6 +1184,7 @@ async def get_task_notes(
note_data = { note_data = {
"id": note.id, "id": note.id,
"content": note.content, "content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id, "parent_note_id": note.parent_note_id,
"task_id": note.task_id, "task_id": note.task_id,
"user_id": note.user_id, "user_id": note.user_id,
@@ -1171,9 +1229,12 @@ async def create_task_note(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
# Artists can only add notes to their own tasks # Artists can only add notes to their own tasks; everyone else needs note:create
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: if current_user.role == UserRole.ARTIST:
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task") 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 # Verify parent note exists if specified
if note.parent_note_id: if note.parent_note_id:
@@ -1192,6 +1253,7 @@ async def create_task_note(
task_id=task_id, task_id=task_id,
user_id=current_user.id, user_id=current_user.id,
content=note.content, content=note.content,
note_type=note.note_type,
parent_note_id=note.parent_note_id parent_note_id=note.parent_note_id
) )
db.add(db_note) db.add(db_note)
@@ -1206,6 +1268,7 @@ async def create_task_note(
note_data = { note_data = {
"id": db_note.id, "id": db_note.id,
"content": db_note.content, "content": db_note.content,
"note_type": db_note.note_type,
"parent_note_id": db_note.parent_note_id, "parent_note_id": db_note.parent_note_id,
"task_id": db_note.task_id, "task_id": db_note.task_id,
"user_id": db_note.user_id, "user_id": db_note.user_id,
@@ -1244,9 +1307,12 @@ async def update_task_note(
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
# Users can only update their own notes, unless they have admin permission # Editing your own note requires note:edit_self; editing someone else's requires note:edit_other
if note.user_id != current_user.id and not current_user.is_admin: if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized to update this note") 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 note.content = note_update.content
db.commit() db.commit()
@@ -1255,6 +1321,7 @@ async def update_task_note(
note_data = { note_data = {
"id": note.id, "id": note.id,
"content": note.content, "content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id, "parent_note_id": note.parent_note_id,
"task_id": note.task_id, "task_id": note.task_id,
"user_id": note.user_id, "user_id": note.user_id,
@@ -1290,9 +1357,12 @@ async def delete_task_note(
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
# Users can only delete their own notes, unless they have admin permission # Deleting your own note requires note:delete_self; deleting someone else's requires note:delete_other
if note.user_id != current_user.id and not current_user.is_admin: if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized to delete this note") 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) db.delete(note)
db.commit() db.commit()
@@ -1368,9 +1438,12 @@ async def upload_task_attachment(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
# Artists can only upload attachments to their own tasks # Artists can only upload attachments to their own tasks; everyone else needs upload:create
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: if current_user.role == UserRole.ARTIST:
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task") 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 # Validate file using file handler
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db) file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
@@ -1443,9 +1516,9 @@ async def delete_task_attachment(
if not attachment: if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found") raise HTTPException(status_code=404, detail="Attachment not found")
# Users can only delete their own attachments, unless they're admin/coordinator # Users can only delete their own attachments, unless they have admin permission or upload:delete
if (attachment.user_id != current_user.id and if (attachment.user_id != current_user.id and not current_user.is_admin
not current_user.is_admin and current_user.role != UserRole.COORDINATOR): and not user_has_permission(current_user, 'upload', 'delete', db)):
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment") raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
# Delete file from filesystem using file handler # Delete file from filesystem using file handler
@@ -1534,8 +1607,9 @@ async def submit_work(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
# Only assigned artist can submit work # Only the assigned artist can submit work, unless the caller holds submission:create
if task.assigned_user_id != current_user.id: 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") raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
# Validate file using file handler # Validate file using file handler
@@ -1600,3 +1674,88 @@ async def submit_work(
} }
return SubmissionResponse(**submission_data) 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"}
+49 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File 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 typing import List, Optional
from passlib.context import CryptContext from passlib.context import CryptContext
from pathlib import Path from pathlib import Path
@@ -12,7 +12,9 @@ from database import get_db
from models.user import User, UserRole from models.user import User, UserRole
from models.project import ProjectMember from models.project import ProjectMember
from models.task import Task 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.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 from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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) @router.put("/{user_id}/admin", response_model=dict)
async def update_user_admin_permission( async def update_user_admin_permission(
user_id: int, user_id: int,
@@ -149,7 +187,7 @@ async def list_users(
current_user: User = Depends(require_admin_or_coordinator) current_user: User = Depends(require_admin_or_coordinator)
): ):
"""List all users (Admin and Coordinator only).""" """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 return users
@@ -169,7 +207,10 @@ async def get_current_user_profile(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Get current user's profile.""" """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 = _get_user_from_db(db, token_data["user_id"])
current_user.permissions = compute_effective_permissions(current_user, db)
return current_user return current_user
@@ -214,7 +255,7 @@ async def get_user(
current_user: User = Depends(require_admin_or_coordinator) current_user: User = Depends(require_admin_or_coordinator)
): ):
"""Get user by ID (Admin and Coordinator only).""" """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: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -256,6 +297,11 @@ async def admin_create_user(
db.commit() db.commit()
db.refresh(new_user) 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 return new_user
+104
View File
@@ -0,0 +1,104 @@
"""
Pydantic schemas for department management
"""
from pydantic import BaseModel, Field, validator
from typing import List, Literal
import re
DEPARTMENT_NAME_PATTERN = r'^[a-z0-9_]{2,50}$'
class CustomDepartmentCreate(BaseModel):
"""Schema for creating a new custom department"""
department: str = Field(..., min_length=2, max_length=50, description="Department name")
department_type: Literal["shot", "asset"] = Field(..., description="Whether this department applies to shots or assets")
task_types: List[str] = Field(default_factory=list, description="Task types owned by this department")
@validator('department')
def validate_department_name(cls, v):
"""Validate department name format"""
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
@validator('task_types', each_item=True)
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class CustomDepartmentUpdate(BaseModel):
"""Schema for updating a custom department name"""
old_name: str = Field(..., description="Current department name")
new_name: str = Field(..., min_length=2, max_length=50, description="New department name")
@validator('new_name')
def validate_department_name(cls, v):
"""Validate department name format"""
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Department name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentTaskTypeCreate(BaseModel):
"""Schema for adding a task type to a custom department"""
task_type: str = Field(..., min_length=2, max_length=50, description="Task type name")
@validator('task_type')
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentTaskTypeUpdate(BaseModel):
"""Schema for renaming a task type within a custom department"""
old_name: str = Field(..., description="Current task type name")
new_name: str = Field(..., min_length=2, max_length=50, description="New task type name")
@validator('new_name')
def validate_task_type_name(cls, v):
if not re.match(DEPARTMENT_NAME_PATTERN, v):
raise ValueError(
'Task type name must be 2-50 characters, lowercase alphanumeric with underscores only'
)
return v
class DepartmentInfo(BaseModel):
"""A single department: its name, whether it applies to shots or assets, and its owned task types"""
name: str
type: Literal["shot", "asset"]
task_types: List[str]
class AllDepartmentsResponse(BaseModel):
"""Schema for response containing all departments (standard + custom)"""
departments: List[DepartmentInfo] = Field(..., description="All departments")
standard_departments: List[DepartmentInfo] = Field(..., description="Standard departments (read-only)")
custom_departments: List[DepartmentInfo] = Field(..., description="Custom departments")
class DepartmentInUseError(BaseModel):
"""Schema for error when trying to delete a department in use"""
error: str = Field(..., description="Error message")
department: str = Field(..., description="Department that is in use")
member_count: int = Field(..., description="Number of project members using this department")
task_count: int = Field(..., description="Number of tasks using this department")
class DepartmentTaskTypeInUseError(BaseModel):
"""Schema for error when trying to delete a department task type in use"""
error: str = Field(..., description="Error message")
department: str = Field(..., description="Department the task type belongs to")
task_type: str = Field(..., description="Task type that is in use")
task_count: int = Field(..., description="Number of tasks using this task type")
+2 -35
View File
@@ -5,7 +5,6 @@ from enum import Enum
import re import re
from models.project import ProjectStatus, ProjectType from models.project import ProjectStatus, ProjectType
from models.user import DepartmentRole
# Technical Specifications Schemas # Technical Specifications Schemas
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
# Default delivery movie specifications per department # Default delivery movie specifications per department
DEFAULT_DELIVERY_MOVIE_SPECS = { DEFAULT_DELIVERY_MOVIE_SPECS = {
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase):
pass pass
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")') raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return v
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectMemberBase(BaseModel): class ProjectMemberBase(BaseModel):
user_id: int user_id: int
department_role: Optional[DepartmentRole] = None department_role: Optional[str] = None
class ProjectMemberCreate(ProjectMemberBase): class ProjectMemberCreate(ProjectMemberBase):
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberUpdate(BaseModel): class ProjectMemberUpdate(BaseModel):
department_role: Optional[DepartmentRole] = None department_role: Optional[str] = None
class ProjectMemberResponse(ProjectMemberBase): class ProjectMemberResponse(ProjectMemberBase):
+52
View File
@@ -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]
+114
View File
@@ -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
View File
@@ -3,16 +3,17 @@ from typing import Optional, List
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
from models.user import DepartmentRole
class TaskBase(BaseModel): class TaskBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
task_type: str # Changed from TaskType enum to str to support custom task types task_type: str # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
@@ -27,8 +28,10 @@ class TaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
assigned_user_id: Optional[int] = None assigned_user_id: Optional[int] = None
@@ -68,6 +71,8 @@ class TaskListResponse(BaseModel):
name: str name: str
task_type: str # Changed from TaskType enum to str to support custom task types task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses status: str # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
start_date: Optional[date] = None
deadline: Optional[date] = None deadline: Optional[date] = None
project_id: int project_id: int
project_name: str project_name: str
@@ -94,7 +99,7 @@ class ProductionNoteBase(BaseModel):
class ProductionNoteCreate(ProductionNoteBase): class ProductionNoteCreate(ProductionNoteBase):
pass note_type: NoteType = NoteType.INTERNAL
class ProductionNoteUpdate(BaseModel): class ProductionNoteUpdate(BaseModel):
@@ -105,6 +110,7 @@ class ProductionNoteResponse(ProductionNoteBase):
id: int id: int
task_id: int task_id: int
user_id: int user_id: int
note_type: NoteType
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -162,6 +168,10 @@ class SubmissionCreate(SubmissionBase):
pass pass
class SubmissionUpdate(BaseModel):
notes: Optional[str] = None
class SubmissionResponse(SubmissionBase): class SubmissionResponse(SubmissionBase):
id: int id: int
task_id: int task_id: int
@@ -187,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
from_attributes = True from_attributes = True
class SubmissionDateInfo(BaseModel):
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
task_id: int
submitted_at: datetime
class Config:
from_attributes = True
# Review schemas # Review schemas
class ReviewBase(BaseModel): class ReviewBase(BaseModel):
decision: ReviewDecision decision: ReviewDecision
@@ -214,7 +233,7 @@ class ReviewResponse(ReviewBase):
# Bulk action schemas # Bulk action schemas
class BulkStatusUpdate(BaseModel): class BulkStatusUpdate(BaseModel):
task_ids: List[int] = Field(..., min_length=1) 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): class BulkAssignment(BaseModel):
+4 -1
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional, List
from datetime import datetime from datetime import datetime
from models.user import UserRole from models.user import UserRole
from schemas.role import RoleSummary
class UserBase(BaseModel): class UserBase(BaseModel):
@@ -30,6 +31,8 @@ class UserResponse(UserBase):
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
roles: List[RoleSummary] = []
permissions: Optional[List[str]] = None
class Config: class Config:
from_attributes = True from_attributes = True
+95
View File
@@ -244,6 +244,101 @@ def require_admin_permission():
return admin_checker 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): def create_role_dependency(required_roles: list):
"""Create a dependency that requires specific user roles with proper DB injection.""" """Create a dependency that requires specific user roles with proper DB injection."""
def role_checker( def role_checker(
+21
View File
@@ -0,0 +1,21 @@
"""Shared helpers for resolving the department that owns a given task type."""
from typing import Optional
def find_owning_department(db_project, task_type: str) -> Optional[str]:
"""Return the name of the department (standard or custom) whose task_types
list contains task_type for this project, or None if no department owns it."""
if not task_type:
return None
from routers.projects import STANDARD_DEPARTMENTS
for department in STANDARD_DEPARTMENTS:
if task_type in department["task_types"]:
return department["name"]
for department in (db_project.custom_departments or []):
if task_type in department.get("task_types", []):
return department["name"]
return None
+5 -3
View File
@@ -21,13 +21,15 @@ class FileHandler:
# Supported VFX media formats # Supported VFX media formats
SUPPORTED_FORMATS = { SUPPORTED_FORMATS = {
# Video formats # Video formats
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
# Image formats # Image formats
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr', '.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
# Document formats # Document formats
'.pdf', '.txt', '.doc', '.docx', '.pdf', '.txt', '.doc', '.docx',
# Archive formats # Archive formats
'.zip', '.rar', '.7z' '.zip', '.rar', '.7z',
# Scene formats
'.ma', '.usd', '.usda', '.usdc'
} }
# File size limits (in bytes) # File size limits (in bytes)
@@ -35,7 +37,7 @@ class FileHandler:
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback) MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
# Movie file extensions that should use global upload limit # Movie file extensions that should use global upload limit
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'} MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
# Thumbnail settings # Thumbnail settings
THUMBNAIL_SIZE = (200, 200) THUMBNAIL_SIZE = (200, 200)
+137
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0", "lucide-vue-next": "^0.294.0",
"mediainfo.js": "^0.3.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"reka-ui": "^2.6.1", "reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2", "shadcn-vue": "^2.3.2",
@@ -2789,6 +2790,60 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4053,6 +4108,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": { "node_modules/get-east-asian-width": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -4817,6 +4881,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/mediainfo.js": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz",
"integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==",
"license": "BSD-2-Clause",
"dependencies": {
"yargs": "^18.0.0"
},
"bin": {
"mediainfo.js": "dist/esm/cli.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
@@ -7595,12 +7674,70 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0", "lucide-vue-next": "^0.294.0",
"mediainfo.js": "^0.3.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"reka-ui": "^2.6.1", "reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2", "shadcn-vue": "^2.3.2",
@@ -10,16 +10,9 @@
/> />
<!-- Asset Details --> <!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto"> <div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')"> <DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges> <template #badges>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<!-- Deletion status indicator for admins --> <!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0"> <Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }} Deleted {{ formatDeletedDate(asset.deleted_at) }}
@@ -28,18 +21,28 @@
</DetailPanelHeader> </DetailPanelHeader>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col"> <Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger> <TabsTrigger value="infos" title="Infos">
<TabsTrigger value="notes"> <Info class="h-4 w-4" />
Notes <span class="sr-only">Infos</span>
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="references"> <TabsTrigger value="notes" title="Notes">
References <span class="relative inline-flex">
<Badge v-if="references.length > 0" variant="secondary" class="ml-2"> <MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ references.length }} {{ references.length }}
</Badge> </Badge>
</TabsTrigger> </TabsTrigger>
@@ -252,7 +255,13 @@
<!-- Notes Tab --> <!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden"> <TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" /> <EntityNotes
:key="assetId"
:tasks="tasks"
:notes="notes"
:submissions="submissions"
@notes-updated="loadNotes"
/>
</TabsContent> </TabsContent>
<!-- References Tab --> <!-- References Tab -->
@@ -267,7 +276,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
Plus, MessageSquarePlus, Paperclip, Send Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -277,13 +286,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue' import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue' import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue' import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import AssetNotes from './AssetNotes.vue' import EntityNotes from '@/components/shared/EntityNotes.vue'
import AssetReferences from './AssetReferences.vue' import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset' import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService } from '@/services/task' import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task { interface Task {
id: number id: number
@@ -312,10 +322,12 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore() const authStore = useAuthStore()
const userStore = useUserStore() const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state // Reactive state
const asset = ref<Asset | null>(null) const asset = ref<Asset | null>(null)
const notes = ref<any[]>([]) const notes = ref<ProductionNote[]>([])
const submissions = ref<Submission[]>([])
const references = ref<any[]>([]) const references = ref<any[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -353,9 +365,15 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100) return Math.round((completedTasksCount.value / tasks.value.length) * 100)
}) })
// Task types offered in "Add Task" include the flat asset task type list
// plus every task type owned by an asset department (e.g. Modeling's own
// task types), deduped against types the asset already has a task for.
const availableTaskTypes = computed(() => { const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type)) const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type)) const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
}) })
const taskStatusCounts = computed(() => { const taskStatusCounts = computed(() => {
@@ -384,6 +402,7 @@ const loadAssetDetails = async () => {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
asset.value = await assetService.getAsset(props.assetId) asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution) // Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) { if (userStore.users.length === 0) {
@@ -402,28 +421,18 @@ const loadAssetDetails = async () => {
} }
const loadNotes = async () => { const loadNotes = async () => {
if (tasks.value.length === 0) {
notes.value = []
submissions.value = []
return
}
try { try {
// Load notes from all tasks associated with this asset const [notesByTask, submissionsByTask] = await Promise.all([
const { taskService } = await import('@/services/task') Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
const allNotes: any[] = [] Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
for (const task of tasks.value) { notes.value = notesByTask.flat()
if (task.id) { submissions.value = submissionsByTask.flat()
const taskNotes = await taskService.getTaskNotes(task.id)
// Add task info to each note for context
const notesWithContext = taskNotes.map(note => ({
...note,
task_name: task.name,
task_type: task.task_type
}))
allNotes.push(...notesWithContext)
}
}
// Sort by date (newest first)
notes.value = allNotes.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
} catch (err) { } catch (err) {
console.error('Failed to load notes:', err) console.error('Failed to load notes:', err)
} }
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-col h-full">
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet for this asset's tasks.</p>
</div>
<div
v-for="note in notes"
:key="note.id"
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">{{ note.author_name }}</span>
<Badge variant="outline" class="text-xs">
{{ formatTaskType(note.task_type) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ note.task_name }} {{ formatDate(note.created_at) }}
</p>
</div>
</div>
<!-- Note Content -->
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MessageSquarePlus } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
const props = defineProps<{
assetId: number
notes: any[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
+81 -4
View File
@@ -8,13 +8,53 @@
<Breadcrumb class="flex-1"> <Breadcrumb class="flex-1">
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index"> <BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
<BreadcrumbLink v-if="crumb.href" :href="crumb.href"> <DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
{{ project.name }}
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<router-link to="/projects">All Projects</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
{{ tabItem.label }}
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
{{ crumb.label }} {{ crumb.label }}
</BreadcrumbLink> </BreadcrumbLink>
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }"> <BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
{{ crumb.label }} {{ crumb.label }}
</BreadcrumbPage> </BreadcrumbPage>
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" /> <BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -76,7 +116,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -97,10 +137,11 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb' } from '@/components/ui/breadcrumb'
import { User, Settings, LogOut } from 'lucide-vue-next' import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb' import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue' import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
import NotificationCenter from './NotificationCenter.vue' import NotificationCenter from './NotificationCenter.vue'
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
// Generate breadcrumbs based on current route with enhanced context // Generate breadcrumbs based on current route with enhanced context
const breadcrumbs = ref<BreadcrumbData[]>([]) const breadcrumbs = ref<BreadcrumbData[]>([])
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
const projectIdParam = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
})
const currentTab = computed(() => route.meta?.tab as string | undefined)
const projectTabItems = [
{ tab: 'overview', label: 'Overview', path: '' },
{ tab: 'shots', label: 'Shots', path: '/shots' },
{ tab: 'assets', label: 'Assets', path: '/assets' },
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
{ tab: 'settings', label: 'Settings', path: '/settings' }
]
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
const projectsStore = useProjectsStore()
const projectsForSwitcher = computed(() => projectsStore.projects)
onMounted(() => {
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
projectsStore.fetchProjects()
}
})
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
// so the trailing ">" separator can be skipped for it.
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
if (crumb.isTabCrumb) return !!projectIdParam.value
return false
}
const updateBreadcrumbs = async () => { const updateBreadcrumbs = async () => {
try { try {
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route) breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
+106 -7
View File
@@ -27,12 +27,65 @@
<SidebarGroupLabel>Navigation</SidebarGroupLabel> <SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem v-for="item in navigationItems" :key="item.title"> <SidebarMenuItem v-for="item in navigationItems" :key="item.title">
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined"> <!-- Collapsed sidebar + Projects (with an active project): hovering reveals sub-links,
<router-link :to="item.url" class="flex items-center gap-2"> while the icon itself still navigates to /projects like any other nav item. -->
<component :is="item.icon" class="size-4" /> <div
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span> v-if="item.title === 'Projects' && isCollapsed && projectTabs.length > 0"
</router-link> @mouseenter="showProjectFlyout = true"
</SidebarMenuButton> @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> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
@@ -93,8 +146,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -105,6 +159,9 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar, useSidebar,
SidebarProps SidebarProps
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
@@ -120,6 +177,12 @@ import {
Database, Database,
BarChart3, BarChart3,
RotateCcw, RotateCcw,
LayoutDashboard,
Camera,
Package,
ListTodo,
ShieldCheck,
GanttChartSquare,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -131,6 +194,7 @@ const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission() const { isCoordinatorOrAdmin } = usePermission()
const { state } = useSidebar() const { state } = useSidebar()
const route = useRoute() const route = useRoute()
const showProjectFlyout = ref(false)
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const userRole = computed(() => authStore.user?.role || 'artist') const userRole = computed(() => authStore.user?.role || 'artist')
@@ -142,6 +206,40 @@ const isOnShotPage = computed(() => {
return route.path.endsWith('/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>(), { const props = withDefaults(defineProps<SidebarProps>(), {
collapsible: "icon", collapsible: "icon",
}) })
@@ -177,6 +275,7 @@ const navigationItems = computed(() => {
// Admin-specific navigation items // Admin-specific navigation items
const adminItems = computed(() => [ const adminItems = computed(() => [
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw }, { title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
{ title: 'Role Management', url: '/admin/roles', icon: ShieldCheck },
]) ])
// Developer-specific navigation items // Developer-specific navigation items
@@ -104,7 +104,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle, Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench, rigging: Wrench,
surfacing: Paintbrush surfacing: Paintbrush
} }
return icons[department] || Box // Fallback for project-custom departments not in the standard icon map above
return icons[department] || Tag
} }
const getFrameRateLabel = (frameRate: number) => { const getFrameRateLabel = (frameRate: number) => {
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user' import { userService } from '@/services/user'
import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
interface Props { interface Props {
@@ -248,16 +249,14 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl() const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = [ const departmentRoles = computed(() => {
{ value: 'layout', label: 'Layout' }, return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
{ value: 'animation', label: 'Animation' }, value: department,
{ value: 'lighting', label: 'Lighting' }, label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
{ value: 'composite', label: 'Composite' }, }))
{ value: 'modeling', label: 'Modeling' }, })
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
// State // State
const members = ref<ProjectMember[]>([]) const members = ref<ProjectMember[]>([])
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
loadMembers() loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
}) })
</script> </script>
@@ -1,160 +0,0 @@
<template>
<div class="w-full">
<div class="grid w-full grid-cols-5 h-auto bg-muted/50 p-1 rounded-md">
<button
v-for="tab in tabs"
:key="tab.id"
@click="setActiveTab(tab.id)"
:class="[
'flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 px-1 sm:px-3 min-h-[3rem] sm:min-h-[2.5rem] transition-all duration-200 text-center rounded-sm',
activeTab === tab.id
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
>
<div class="flex items-center gap-1 sm:gap-2">
<component :is="tab.icon" class="h-4 w-4 flex-shrink-0" />
<span class="hidden sm:inline text-sm font-medium">{{ tab.label }}</span>
<span class="sm:hidden text-xs font-medium">{{ getMobileLabel(tab) }}</span>
</div>
<Badge
v-if="tab.count !== undefined"
variant="secondary"
class="text-xs hidden sm:inline-flex min-w-[1.5rem] h-5"
>
{{ tab.count }}
</Badge>
<!-- Mobile count display -->
<div
v-if="tab.count !== undefined"
class="sm:hidden text-xs text-muted-foreground font-medium"
>
{{ tab.count }}
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LayoutDashboard, Camera, Package, ListTodo, Settings } from "lucide-vue-next";
import { Badge } from "@/components/ui/badge";
interface Tab {
id: string;
label: string;
icon: any;
route: string;
count?: number;
}
interface Props {
projectId: number;
shotCount?: number;
assetCount?: number;
taskCount?: number;
}
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
// Define available tabs
const tabs = computed<Tab[]>(() => [
{
id: "overview",
label: "Overview",
icon: LayoutDashboard,
route: `/projects/${props.projectId}`,
},
{
id: "shots",
label: "Shots",
icon: Camera,
route: `/projects/${props.projectId}/shots`,
count: props.shotCount,
},
{
id: "assets",
label: "Assets",
icon: Package,
route: `/projects/${props.projectId}/assets`,
count: props.assetCount,
},
{
id: "tasks",
label: "Tasks",
icon: ListTodo,
route: `/projects/${props.projectId}/tasks`,
count: props.taskCount,
},
{
id: "settings",
label: "Settings",
icon: Settings,
route: `/projects/${props.projectId}/settings`,
},
]);
// Determine active tab based on current route
const activeTab = computed(() => {
const currentPath = route.path;
if (currentPath === `/projects/${props.projectId}`) {
return "overview";
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
return "shots";
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
return "assets";
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
return "tasks";
} else if (
currentPath.startsWith(`/projects/${props.projectId}/settings`)
) {
return "settings";
}
return "overview";
});
// Set active tab and navigate
const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId);
if (tab) {
router.push(tab.route).catch(() => {
// Navigation aborted (e.g. duplicate route) safe to ignore
});
}
};
// Get mobile label for tabs
const getMobileLabel = (tab: Tab) => {
switch (tab.id) {
case "settings":
return "Settings";
case "overview":
return "Info";
case "shots":
return "Shots";
case "assets":
return "Assets";
case "tasks":
return "Tasks";
default:
return tab.label.charAt(0);
}
};
// Watch for route changes to ensure tab state persistence
watch(
() => route.path,
() => {
// Tab state is automatically updated via activeTab computed property
// This ensures tab state persistence during project navigation
},
{ immediate: true }
);
</script>
@@ -0,0 +1,175 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Submission Configuration</h3>
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
</div>
</div>
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
Loading submission configuration...
</div>
<Tabs v-else default-value="shot" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="shot">Shot Tasks</TabsTrigger>
<TabsTrigger value="asset">Asset Tasks</TabsTrigger>
</TabsList>
<TabsContent value="shot" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in shotTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No shot task types configured for this project yet.
</div>
</TabsContent>
<TabsContent value="asset" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in assetTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No asset task types configured for this project yet.
</div>
</TabsContent>
</Tabs>
<div class="flex justify-end">
<Button :disabled="isLoading || isSaving" @click="onSave">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Save Submission Configuration
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const { toast } = useToast()
const isLoading = ref(false)
const isSaving = ref(false)
const shotTaskTypes = ref<string[]>([])
const assetTaskTypes = ref<string[]>([])
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
const getConfig = (taskType: string): SubmissionTypeConfig => {
if (!formState[taskType]) {
formState[taskType] = {
allowed_extensions: [],
naming_pattern_is_regex: false,
naming_pattern: '',
check_naming: true,
required: false,
check_movie_spec: false,
movie_resolution: '',
movie_format: '',
movie_codec: '',
movie_frame_rate: null
}
}
return formState[taskType]
}
const load = async () => {
try {
isLoading.value = true
const [allTypes, config] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
projectService.getProjectSubmissionConfig(props.projectId)
])
shotTaskTypes.value = allTypes.shot_task_types
assetTaskTypes.value = allTypes.asset_task_types
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
formState[taskType] = {
allowed_extensions: [...cfg.allowed_extensions],
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
naming_pattern: cfg.naming_pattern || '',
check_naming: cfg.check_naming,
required: cfg.required,
check_movie_spec: cfg.check_movie_spec,
movie_resolution: cfg.movie_resolution || '',
movie_format: cfg.movie_format || '',
movie_codec: cfg.movie_codec || '',
movie_frame_rate: cfg.movie_frame_rate ?? null
}
}
} catch (error) {
console.error('Failed to load submission configuration:', error)
toast({
title: 'Error',
description: 'Failed to load submission configuration',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const onSave = async () => {
try {
isSaving.value = true
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
const config = formState[taskType]
if (!config) continue
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|| config.required || config.check_movie_spec
if (hasConfig) {
configByTaskType[taskType] = {
allowed_extensions: config.allowed_extensions,
naming_pattern_is_regex: config.naming_pattern_is_regex,
naming_pattern: config.naming_pattern || null,
check_naming: config.check_naming,
required: config.required,
check_movie_spec: config.check_movie_spec,
movie_resolution: config.movie_resolution || null,
movie_format: config.movie_format || null,
movie_codec: config.movie_codec || null,
movie_frame_rate: config.movie_frame_rate || null
}
}
}
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
toast({
title: 'Submission Configuration Updated',
description: 'Submission rules have been saved successfully'
})
} catch (error: any) {
console.error('Failed to save submission configuration:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to save submission configuration',
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,173 @@
<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
<Switch
:id="`${taskType}_required`"
:model-value="config.required"
@update:model-value="(val) => (config.required = !!val)"
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label class="text-xs">Allowed File Types</Label>
<div class="flex flex-wrap gap-3 mt-1">
<label
v-for="ext in ALLOWED_EXTENSIONS"
:key="ext"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="config.allowed_extensions.includes(ext)"
@update:model-value="(val) => toggleExtension(ext, !!val)"
/>
{{ ext }}
</label>
</div>
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
:model-value="config.naming_pattern_is_regex"
@update:model-value="(val) => (config.naming_pattern_is_regex = !!val)"
/>
Regex
</label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_naming`"
:model-value="config.check_naming"
@update:model-value="(val) => (config.check_naming = !!val)"
/>
</div>
</div>
</div>
<Input
:id="`${taskType}_pattern`"
v-model="config.naming_pattern"
:placeholder="config.naming_pattern_is_regex ? 'e.g. ^[A-Z]+\\d{3}_layout_v\\d{3}$' : 'e.g. {project_code}_{name}_{task_type}_v{version}'"
class="h-8 font-mono"
/>
<p v-if="config.naming_pattern_is_regex" class="text-xs text-muted-foreground mt-1">
Matched as a regular expression against the filename (without extension)
</p>
<p v-else class="text-xs text-muted-foreground mt-1">
Tokens: <code>{name}</code> (shot/asset name), <code>{task_name}</code>, <code>{task_type}</code>,
<code>{project_name}</code>, <code>{project_code}</code>, <code>{version}</code> (auto 3-digit)
</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label class="text-xs">Movie Spec</Label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_movie_spec`"
:model-value="config.check_movie_spec"
@update:model-value="(val) => (config.check_movie_spec = !!val)"
/>
</div>
</div>
<div v-if="config.check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
<div>
<Input
v-model="config.movie_resolution"
placeholder="e.g. 1920x1080"
class="h-8"
/>
</div>
<div>
<Select
:model-value="config.movie_format || undefined"
@update:model-value="(val) => (config.movie_format = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_codec || undefined"
@update:model-value="(val) => (config.movie_codec = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Codec" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_frame_rate ? String(config.movie_frame_rate) : undefined"
@update:model-value="(val) => (config.movie_frame_rate = val ? Number(val) : null)"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Frame rate" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { SubmissionTypeConfig } from '@/services/project'
const props = defineProps<{
taskType: string
config: SubmissionTypeConfig
}>()
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
const ALLOWED_EXTENSIONS = [
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
'.pdf', '.txt', '.doc', '.docx',
'.zip', '.rar', '.7z',
'.ma', '.usd', '.usda', '.usdc'
]
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
function toggleExtension(ext: string, checked: boolean) {
if (checked) {
if (!props.config.allowed_extensions.includes(ext)) props.config.allowed_extensions.push(ext)
} else {
props.config.allowed_extensions = props.config.allowed_extensions.filter(e => e !== ext)
}
}
</script>
@@ -0,0 +1,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"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<h3 class="text-lg font-semibold">Custom Task Types</h3> <h3 class="text-lg font-semibold">Task Types</h3>
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
Add custom task types beyond the standard types to adapt the pipeline to your project needs Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
</p> </p>
</div> </div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List --> <!-- Asset Task Types List -->
<div class="border rounded-lg divide-y"> <div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0"> <template v-if="assetTaskTypeRows.length > 0">
<div <div
v-for="taskType in assetTaskTypes" v-for="row in assetTaskTypeRows"
:key="taskType" :key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span> <span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary"> <Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
Standard <Badge v-else variant="outline">Custom</Badge>
</Badge> <Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
</div> </div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2"> <div v-if="!row.isStandard" class="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="openEditDialog('asset', taskType)" @click="openEditDialog('asset', row.name, row.department)"
> >
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleDelete('asset', taskType)" @click="handleDelete('asset', row.name, row.department)"
> >
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List --> <!-- Shot Task Types List -->
<div class="border rounded-lg divide-y"> <div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0"> <template v-if="shotTaskTypeRows.length > 0">
<div <div
v-for="taskType in shotTaskTypes" v-for="row in shotTaskTypeRows"
:key="taskType" :key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span> <span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary"> <Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
Standard <Badge v-else variant="outline">Custom</Badge>
</Badge> <Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
</div> </div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2"> <div v-if="!row.isStandard" class="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="openEditDialog('shot', taskType)" @click="openEditDialog('shot', row.name, row.department)"
> >
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleDelete('shot', taskType)" @click="handleDelete('shot', row.name, row.department)"
> >
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -155,7 +149,25 @@
{{ validationError }} {{ validationError }}
</p> </p>
<p v-else class="text-sm text-muted-foreground"> <p v-else class="text-sm text-muted-foreground">
3-50 characters, lowercase alphanumeric with underscores only {{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Department (optional)</Label>
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
<SelectTrigger>
<SelectValue placeholder="None (general purpose)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (general purpose)</SelectItem>
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
{{ formatTaskTypeName(department.name) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
</p> </p>
</div> </div>
</div> </div>
@@ -181,6 +193,7 @@
// Only clear values when dialog closes and we're not in the middle of deleting // Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = '' taskTypeToDelete = ''
categoryToDelete = '' categoryToDelete = ''
departmentToDelete = ''
deleteError = '' deleteError = ''
} }
}" }"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType' import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
import { departmentService, type DepartmentInfo } from '@/services/department'
import { useDepartmentsStore } from '@/stores/departments'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
interface Props { interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>() }>()
const { toast } = useToast() const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State // State
const isLoading = ref(true) const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false) const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add') const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset') const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('') const taskTypeName = ref('')
const originalTaskTypeName = ref('') const originalTaskTypeName = ref('')
const validationError = ref('') const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false) const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('') const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset') const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('') const deleteError = ref('')
const isDeleting = ref(false) const isDeleting = ref(false)
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || []) const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || []) const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
const dialogCustomDepartments = computed(() => {
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
})
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
const rows = new Map<string, TaskTypeRow>()
for (const t of flatAll) {
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
}
for (const department of departments) {
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
for (const t of department.task_types) {
const existing = rows.get(t)
if (existing) {
existing.department = department.name
existing.isStandard = existing.isStandard || isDeptStandard
} else {
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
}
}
}
return Array.from(rows.values())
}
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
const isTaskTypeNameValid = computed(() => { const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
}) })
// Methods // Methods
const loadTaskTypes = async () => { const loadTaskTypes = async () => {
try { try {
isLoading.value = true isLoading.value = true
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId) const [types] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
departmentsStore.fetchProjectDepartments(props.projectId, true)
])
taskTypes.value = types
} catch (error: any) { } catch (error: any) {
console.error('Failed to load task types:', error) console.error('Failed to load task types:', error)
toast({ toast({
@@ -298,28 +361,21 @@ const loadTaskTypes = async () => {
} }
} }
const isStandardAssetType = (taskType: string): boolean => {
return standardAssetTypes.value.includes(taskType)
}
const isStandardShotType = (taskType: string): boolean => {
return standardShotTypes.value.includes(taskType)
}
const formatTaskTypeName = (taskType: string): string => { const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ') return taskType.replace(/_/g, ' ')
} }
const validateTaskTypeName = () => { const validateTaskTypeName = () => {
const name = taskTypeName.value.trim() const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) { if (name.length === 0) {
validationError.value = '' validationError.value = ''
return return
} }
if (name.length < 3) { if (name.length < minLength) {
validationError.value = 'Task type name must be at least 3 characters' validationError.value = `Task type name must be at least ${minLength} characters`
return return
} }
@@ -333,10 +389,10 @@ const validateTaskTypeName = () => {
return return
} }
// Check for duplicates (only if adding or changing name) // Check for duplicates against the merged (flat + department) rows for this category
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) { if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingTypes.includes(name)) { if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists' validationError.value = 'A task type with this name already exists'
return return
} }
@@ -348,15 +404,17 @@ const validateTaskTypeName = () => {
const openAddDialog = (category: 'asset' | 'shot') => { const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add' dialogMode.value = 'add'
dialogCategory.value = category dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = '' taskTypeName.value = ''
originalTaskTypeName.value = '' originalTaskTypeName.value = ''
validationError.value = '' validationError.value = ''
isDialogOpen.value = true isDialogOpen.value = true
} }
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => { const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit' dialogMode.value = 'edit'
dialogCategory.value = category dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType taskTypeName.value = taskType
originalTaskTypeName.value = taskType originalTaskTypeName.value = taskType
validationError.value = '' validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => { const closeDialog = () => {
isDialogOpen.value = false isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = '' taskTypeName.value = ''
originalTaskTypeName.value = '' originalTaskTypeName.value = ''
validationError.value = '' validationError.value = ''
@@ -381,29 +441,42 @@ const handleDialogSave = async () => {
isSaving.value = true isSaving.value = true
if (dialogMode.value === 'add') { if (dialogMode.value === 'add') {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, { if (dialogDepartment.value) {
task_type: taskTypeName.value.trim(), const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
category: dialogCategory.value departmentsStore.updateProjectDepartments(props.projectId, response)
}) } else {
console.log('Add task type response:', response) const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
taskTypes.value = response task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully` description: `Task type "${taskTypeName.value}" added successfully`
}) })
} else { } else {
const response = await customTaskTypeService.updateCustomTaskType( if (editingDepartment.value) {
props.projectId, const response = await departmentService.renameDepartmentTaskType(
originalTaskTypeName.value, props.projectId,
{ editingDepartment.value,
old_name: originalTaskTypeName.value, originalTaskTypeName.value,
new_name: taskTypeName.value.trim(), taskTypeName.value.trim()
category: dialogCategory.value )
} departmentsStore.updateProjectDepartments(props.projectId, response)
) } else {
console.log('Update task type response:', response) const response = await customTaskTypeService.updateCustomTaskType(
taskTypes.value = response props.projectId,
originalTaskTypeName.value,
{
old_name: originalTaskTypeName.value,
new_name: taskTypeName.value.trim(),
category: dialogCategory.value
}
)
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
@@ -427,9 +500,10 @@ const handleDialogSave = async () => {
} }
} }
const handleDelete = (category: 'asset' | 'shot', taskType: string) => { const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
taskTypeToDelete.value = taskType taskTypeToDelete.value = taskType
categoryToDelete.value = category categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = '' deleteError.value = ''
isDeleteDialogOpen.value = true isDeleteDialogOpen.value = true
} }
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations // Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try { try {
isDeleting.value = true isDeleting.value = true
@@ -454,12 +529,17 @@ const confirmDelete = async () => {
return return
} }
const response = await customTaskTypeService.deleteCustomTaskType( if (departmentToDeleteLocal) {
props.projectId, const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
taskTypeToDeleteLocal, departmentsStore.updateProjectDepartments(props.projectId, response)
categoryToDeleteLocal } else {
) const response = await customTaskTypeService.deleteCustomTaskType(
taskTypes.value = response props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
}
toast({ toast({
title: 'Success', title: 'Success',
@@ -471,16 +551,20 @@ const confirmDelete = async () => {
// Close dialog and clear values // Close dialog and clear values
isDeleteDialogOpen.value = false isDeleteDialogOpen.value = false
taskTypeToDelete.value = '' taskTypeToDelete.value = ''
categoryToDelete.value = '' categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = '' deleteError.value = ''
} catch (error: any) { } catch (error: any) {
console.error('Failed to delete task type:', error) console.error('Failed to delete task type:', error)
const errorData = error.response?.data const errorData = error.response?.data
const detail = errorData?.detail
if (errorData?.task_count) { if (detail?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type` deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
} else if (typeof detail === 'string') {
deleteError.value = detail
} else { } else {
deleteError.value = errorData?.detail || 'Failed to delete task type' deleteError.value = 'Failed to delete task type'
} }
toast({ toast({
@@ -0,0 +1,672 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Departments</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
</p>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Content -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-muted-foreground" />
<h4 class="font-semibold">All Departments</h4>
</div>
<Button size="sm" @click="openAddDialog">
<Plus class="h-4 w-4 mr-2" />
Add Department
</Button>
</div>
<div class="border rounded-lg divide-y">
<template v-if="allDepartments.length > 0">
<div
v-for="department in allDepartments"
:key="department.name"
class="p-3 hover:bg-muted/50 transition-colors space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium capitalize">{{ formatName(department.name) }}</span>
<Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
</div>
<div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
<Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
<Pencil class="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" @click="handleDelete(department.name)">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 pl-1">
<Badge
v-for="taskType in department.task_types"
:key="taskType"
variant="secondary"
class="text-xs font-normal capitalize gap-1"
>
{{ formatName(taskType) }}
<button
v-if="!isStandardDepartment(department.name)"
class="hover:text-destructive"
@click="handleDeleteTaskType(department.name, taskType)"
>
<X class="h-3 w-3" />
</button>
</Badge>
<span v-if="department.task_types.length === 0" class="text-xs text-muted-foreground">
No task types defined
</span>
<Button
v-if="!isStandardDepartment(department.name)"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
@click="openAddTaskTypeDialog(department.name)"
>
<Plus class="h-3 w-3 mr-1" />
Add Task Type
</Button>
</div>
</div>
</template>
<div v-else class="p-4 text-center text-sm text-muted-foreground">
No departments defined
</div>
</div>
</div>
<!-- Add/Edit Department Dialog -->
<Dialog :open="isDialogOpen" @update:open="closeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
: 'Update the department name. This will update all team members and tasks using this department.'
}}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="departmentName">Department Name</Label>
<Input
id="departmentName"
v-model="departmentName"
placeholder="e.g., fx, previz, matchmove"
:class="{ 'border-destructive': validationError }"
@input="validateDepartmentName"
/>
<p v-if="validationError" class="text-sm text-destructive">
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
2-50 characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Type</Label>
<Select v-model="departmentType">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shot">Shot</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Shot departments apply to shot tasks; asset departments apply to asset tasks.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeDialog">
Cancel
</Button>
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Add Task Type Dialog -->
<Dialog :open="isTaskTypeDialogOpen" @update:open="closeTaskTypeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Add Task Type to "{{ formatName(taskTypeDepartment) }}"</DialogTitle>
<DialogDescription>
Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="taskTypeName">Task Type Name</Label>
<Input
id="taskTypeName"
v-model="taskTypeName"
placeholder="e.g., blocking, first_pass"
:class="{ 'border-destructive': taskTypeValidationError }"
@input="validateTaskTypeName"
/>
<p v-if="taskTypeValidationError" class="text-sm text-destructive">
{{ taskTypeValidationError }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeTaskTypeDialog">
Cancel
</Button>
<Button @click="handleAddTaskType" :disabled="!isTaskTypeNameValid || isSavingTaskType">
<div v-if="isSavingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Department Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting) {
departmentToDelete = ''
deleteError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Department</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the department "{{ departmentToDelete }}"?
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
{{ deleteError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDelete"
:disabled="isDeleting"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Delete Task Type Confirmation Dialog -->
<AlertDialog
:open="isDeleteTaskTypeDialogOpen"
@update:open="(open) => {
isDeleteTaskTypeDialogOpen = open
if (!open && !isDeletingTaskType) {
taskTypeToDelete = null
deleteTaskTypeError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the task type "{{ taskTypeToDelete?.taskType }}" from "{{ taskTypeToDelete?.department }}"?
<span v-if="deleteTaskTypeError" class="block mt-2 text-destructive font-medium">
{{ deleteTaskTypeError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDeleteTaskType"
:disabled="isDeletingTaskType"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeletingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useDepartmentsStore } from '@/stores/departments'
import { departmentService, type DepartmentType } from '@/services/department'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
updated: []
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
// State
const isLoading = ref(true)
// Department dialog state
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const departmentName = ref('')
const departmentType = ref<DepartmentType | ''>('')
const originalDepartmentName = ref('')
const validationError = ref('')
const isSaving = ref(false)
// Task type dialog state
const isTaskTypeDialogOpen = ref(false)
const taskTypeDepartment = ref('')
const taskTypeName = ref('')
const taskTypeValidationError = ref('')
const isSavingTaskType = ref(false)
// Delete department dialog state
const isDeleteDialogOpen = ref(false)
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
// Delete task type dialog state
const isDeleteTaskTypeDialogOpen = ref(false)
const taskTypeToDelete = ref<{ department: string; taskType: string } | null>(null)
const deleteTaskTypeError = ref('')
const isDeletingTaskType = ref(false)
// Computed
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
const isDepartmentNameValid = computed(() => {
return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
})
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
})
// Methods
const loadDepartments = async () => {
try {
isLoading.value = true
await departmentsStore.fetchProjectDepartments(props.projectId, true)
} catch (error: any) {
console.error('Failed to load departments:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load departments',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const isStandardDepartment = (department: string): boolean => {
return standardDepartments.value.some(d => d.name === department)
}
const formatName = (name: string): string => {
return name.replace(/_/g, ' ')
}
const validateDepartmentName = () => {
const name = departmentName.value.trim()
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 2) {
validationError.value = 'Department name must be at least 2 characters'
return
}
if (name.length > 50) {
validationError.value = 'Department name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
return
}
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
if (allDepartmentNames.value.includes(name)) {
validationError.value = 'A department with this name already exists'
return
}
}
validationError.value = ''
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
if (name.length === 0) {
taskTypeValidationError.value = ''
return
}
if (name.length < 2 || name.length > 50) {
taskTypeValidationError.value = 'Task type name must be 2-50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
taskTypeValidationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
const department = allDepartments.value.find(d => d.name === taskTypeDepartment.value)
if (department?.task_types.includes(name)) {
taskTypeValidationError.value = 'A task type with this name already exists in this department'
return
}
taskTypeValidationError.value = ''
}
const openAddDialog = () => {
dialogMode.value = 'add'
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (department: string) => {
dialogMode.value = 'edit'
departmentName.value = department
originalDepartmentName.value = department
validationError.value = ''
isDialogOpen.value = true
}
const closeDialog = () => {
isDialogOpen.value = false
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
}
const openAddTaskTypeDialog = (department: string) => {
taskTypeDepartment.value = department
taskTypeName.value = ''
taskTypeValidationError.value = ''
isTaskTypeDialogOpen.value = true
}
const closeTaskTypeDialog = () => {
isTaskTypeDialogOpen.value = false
taskTypeDepartment.value = ''
taskTypeName.value = ''
taskTypeValidationError.value = ''
}
const handleDialogSave = async () => {
validateDepartmentName()
if (!isDepartmentNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await departmentService.addDepartment(props.projectId, {
department: departmentName.value.trim(),
department_type: departmentType.value as DepartmentType
})
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentName.value}" added successfully`
})
} else {
const response = await departmentService.updateDepartment(
props.projectId,
originalDepartmentName.value,
{
old_name: originalDepartmentName.value,
new_name: departmentName.value.trim()
}
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
console.error('Failed to save department:', error)
const errorMessage = error.response?.data?.detail || 'Failed to save department'
validationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
const handleAddTaskType = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSavingTaskType.value = true
const response = await departmentService.addDepartmentTaskType(
props.projectId,
taskTypeDepartment.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
emit('updated')
closeTaskTypeDialog()
} catch (error: any) {
console.error('Failed to add task type:', error)
const errorMessage = error.response?.data?.detail || 'Failed to add task type'
taskTypeValidationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSavingTaskType.value = false
}
}
const handleDelete = (department: string) => {
departmentToDelete.value = department
deleteError.value = ''
isDeleteDialogOpen.value = true
}
const confirmDelete = async () => {
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
deleteError.value = ''
if (!departmentToDeleteLocal) {
deleteError.value = 'Department name is missing. Please try again.'
isDeleting.value = false
return
}
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentToDeleteLocal}" deleted successfully`
})
emit('updated')
isDeleteDialogOpen.value = false
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete department:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
const { task_count, member_count } = errorData.detail
const parts = []
if (member_count) parts.push(`${member_count} team member(s)`)
if (task_count) parts.push(`${task_count} task(s)`)
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
} else {
deleteError.value = errorData?.detail || 'Failed to delete department'
}
toast({
title: 'Error',
description: deleteError.value,
variant: 'destructive'
})
} finally {
isDeleting.value = false
}
}
const handleDeleteTaskType = (department: string, taskType: string) => {
taskTypeToDelete.value = { department, taskType }
deleteTaskTypeError.value = ''
isDeleteTaskTypeDialogOpen.value = true
}
const confirmDeleteTaskType = async () => {
const target = taskTypeToDelete.value
if (!target) return
try {
isDeletingTaskType.value = true
deleteTaskTypeError.value = ''
const response = await departmentService.removeDepartmentTaskType(props.projectId, target.department, target.taskType)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${target.taskType}" deleted successfully`
})
emit('updated')
isDeleteTaskTypeDialogOpen.value = false
taskTypeToDelete.value = null
deleteTaskTypeError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined) {
deleteTaskTypeError.value = `Cannot delete: ${errorData.detail.task_count} task(s) are using this task type`
} else {
deleteTaskTypeError.value = errorData?.detail || 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteTaskTypeError.value,
variant: 'destructive'
})
} finally {
isDeletingTaskType.value = false
}
}
// Lifecycle
onMounted(() => {
loadDepartments()
})
</script>
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
> >
<div <div
v-if="visible" v-if="visible"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto" class="fixed right-0 top-16 bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
> >
<slot /> <slot />
</div> </div>
@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col h-full">
<!-- Filter / Sort Toolbar -->
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-1.5">
<Popover v-if="tasks.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
Tasks
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
{{ taskFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-52 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="taskFilters.length === 0"
@update:model-value="taskFilters = []"
>
All Tasks
</CheckableCommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CheckableCommandItem
v-for="task in tasks"
:key="task.id"
:value="String(task.id)"
:model-value="taskFilters.includes(task.id)"
@update:model-value="toggleTaskFilter(task.id)"
>
{{ formatTaskType(task.task_type) }}
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
size="icon-sm"
@click="showSubmissionNotes = !showSubmissionNotes"
>
<Send class="h-3.5 w-3.5" />
<span class="sr-only">Submission notes</span>
</Button>
</TooltipTrigger>
<TooltipContent>Submission notes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showClientOnly ? 'secondary' : 'outline'"
size="icon-sm"
@click="showClientOnly = !showClientOnly"
>
<Megaphone class="h-3.5 w-3.5" />
<span class="sr-only">Client notes only</span>
</Button>
</TooltipTrigger>
<TooltipContent>Client notes only</TooltipContent>
</Tooltip>
</div>
<Select v-model="sortOrder">
<SelectTrigger class="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="combinedEntries.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet. Start the conversation below.</p>
</div>
<template v-for="entry in combinedEntries" :key="entry.id">
<!-- Production Note -->
<div v-if="entry.kind === 'note'" class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div>
<NoteItem
:note="entry.note"
:task-id="entry.note.task_id"
date-format="absolute"
hide-client-badge
@note-updated="emit('notesUpdated')"
@reply="handleReply"
/>
</div>
<!-- Submission Note (read-only) -->
<div v-else class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
</div>
<div class="flex gap-3">
<Avatar class="h-8 w-8 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
</span>
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateOnly(entry.submission.submitted_at) }}
<Tooltip>
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
</div>
</div>
</div>
</template>
</div>
<!-- Note Input (Bottom) -->
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select task..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
type="button"
size="sm"
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
@click="newNoteType = 'internal'"
>
Internal
</Button>
<Button
type="button"
size="sm"
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
@click="newNoteType = 'client'"
>
Client
</Button>
</div>
<Button
size="sm"
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
@click="handleAddNote"
>
<MessageSquarePlus class="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import NoteItem from '@/components/task/NoteItem.vue'
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface EntityNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: EntityNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
const { toast } = useToast()
const { isCoordinatorOrAdmin } = usePermission()
const { getAvatarUrl } = useAvatarUrl()
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const taskFilters = ref<number[]>([])
const sortOrder = ref<'newest' | 'oldest'>('newest')
const showSubmissionNotes = ref(true)
const showClientOnly = ref(false)
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const targetTaskId = ref<number | null>(null)
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
watch(() => props.tasks, (tasks) => {
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
targetTaskId.value = tasks[0]?.id ?? null
}
}, { immediate: true })
function toggleTaskFilter(taskId: number) {
const index = taskFilters.value.indexOf(taskId)
if (index > -1) taskFilters.value.splice(index, 1)
else taskFilters.value.push(taskId)
}
function isFilterActive(taskId: number): boolean {
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
}
function taskTypeFor(taskId: number): string {
return props.tasks.find(t => t.id === taskId)?.task_type || ''
}
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function formatDateOnly(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
interface NoteEntry {
kind: 'note'
id: string
date: string
note: ProductionNote
}
interface SubmissionEntry {
kind: 'submission'
id: string
date: string
submission: Submission
}
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
const noteEntries: NoteEntry[] = props.notes
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
? props.submissions
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
: []
const direction = sortOrder.value === 'newest' ? -1 : 1
return [...noteEntries, ...submissionEntries].sort(
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
)
})
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
function handleReply(noteId: number) {
const note = findNote(props.notes, noteId)
if (!note) return
replyToNoteId.value = noteId
targetTaskId.value = note.task_id
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
async function handleAddNote() {
if (!newNoteContent.value.trim() || !targetTaskId.value) return
submitting.value = true
try {
await taskService.createTaskNote(
targetTaskId.value,
newNoteContent.value,
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
title: 'Success',
description: 'Note added successfully'
})
} catch (error: any) {
console.error('Error adding note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to add note',
variant: 'destructive'
})
} finally {
submitting.value = false
}
}
</script>
+5 -1
View File
@@ -170,6 +170,7 @@
:key="selectedTaskId" :key="selectedTaskId"
:task-id="selectedTaskId" :task-id="selectedTaskId"
:initial-tab="selectedTaskTab" :initial-tab="selectedTaskTab"
:initial-reply-note-id="selectedTaskReplyNoteId"
@close="selectedTaskId = null" @close="selectedTaskId = null"
@task-updated="loadShots" @task-updated="loadShots"
/> />
@@ -848,16 +849,19 @@ const clearSearch = () => {
const selectedTaskId = ref<number | null>(null) const selectedTaskId = ref<number | null>(null)
const selectedTaskTab = ref<string>('infos') const selectedTaskTab = ref<string>('infos')
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
const handleSelectTask = (task: { id: number }, tab?: string) => { const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
selectedTaskId.value = task.id selectedTaskId.value = task.id
selectedTaskTab.value = tab || 'infos' selectedTaskTab.value = tab || 'infos'
selectedTaskReplyNoteId.value = noteId
} }
// Reset the task sub-panel whenever the shot selection changes (including close) // Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => { watch(selectedShot, () => {
selectedTaskId.value = null selectedTaskId.value = null
selectedTaskTab.value = 'infos' selectedTaskTab.value = 'infos'
selectedTaskReplyNoteId.value = undefined
}) })
const formatStatus = (status: ShotStatus) => { const formatStatus = (status: ShotStatus) => {
+97 -115
View File
@@ -10,16 +10,9 @@
/> />
<!-- Shot Details --> <!-- Shot Details -->
<div v-else-if="shot" class="flex-1 overflow-y-auto"> <div v-else-if="shot" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')"> <DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges> <template #badges>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins --> <!-- Deletion status indicator for admins -->
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0"> <Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }} Deleted {{ formatDeletedDate(shot.deleted_at) }}
@@ -28,17 +21,40 @@
</DetailPanelHeader> </DetailPanelHeader>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col"> <Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger> <TabsTrigger value="infos" title="Infos">
<TabsTrigger value="notes">Notes</TabsTrigger> <Info class="h-4 w-4" />
<TabsTrigger value="assets">Assets</TabsTrigger> <span class="sr-only">Infos</span>
<TabsTrigger value="references">References</TabsTrigger> </TabsTrigger>
<TabsTrigger value="design">Design</TabsTrigger> <TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="shotNotes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="assets" title="Assets">
<Package class="h-4 w-4" />
<span class="sr-only">Assets</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
</TabsTrigger>
<TabsTrigger value="design" title="Design">
<Edit class="h-4 w-4" />
<span class="sr-only">Design</span>
</TabsTrigger>
</TabsList> </TabsList>
<!-- Infos Tab --> <!-- Infos Tab -->
<TabsContent value="infos" class="flex-1 p-6 space-y-6"> <TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
<!-- Shot Information --> <!-- Shot Information -->
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold">Shot Information</h3> <h3 class="text-sm font-semibold">Shot Information</h3>
@@ -171,73 +187,45 @@
<p class="text-sm text-muted-foreground">No tasks yet</p> <p class="text-sm text-muted-foreground">No tasks yet</p>
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p> <p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
</div> </div>
<!-- Tasks Table --> <!-- Tasks Cards -->
<div v-else class="border rounded-lg overflow-hidden"> <div v-else class="space-y-1.5">
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b"> <Card
<div>Task Type</div>
<div>Assignee</div>
<div>Status</div>
</div>
<div
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0" class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
@click="$emit('select-task', task, 'infos')" @click="$emit('select-task', task, 'infos')"
> >
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div> <span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> <div class="flex items-center gap-2 flex-shrink-0">
<Avatar class="h-5 w-5 flex-shrink-0" v-if="task.assigned_user_name"> <Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" /> <AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback> <AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
</Avatar> </Avatar>
<span class="truncate">{{ task.assigned_user_name || 'Unassigned' }}</span> <span v-else class="text-xs text-muted-foreground">Unassigned</span>
</div>
<div>
<TaskStatusBadge :status="getTaskStatusObject(task)" compact /> <TaskStatusBadge :status="getTaskStatusObject(task)" compact />
</div> </div>
</div> </Card>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
<!-- Notes Tab --> <!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 p-6 space-y-4"> <TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<div class="flex items-center justify-between mb-4"> <div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
<h3 class="text-sm font-semibold">Production Notes</h3> Loading notes...
<Popover v-if="canCreateNote">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
<Plus class="h-3 w-3 mr-1" />
Add Note
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
</div> </div>
<EntityNotes
v-else
:key="shotId"
:tasks="tasks"
:notes="shotNotes"
:submissions="shotSubmissions"
@notes-updated="loadShotNotes"
/>
</TabsContent> </TabsContent>
<!-- Assets Tab --> <!-- Assets Tab -->
<TabsContent value="assets" class="flex-1 p-6"> <TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3> <h3 class="text-sm font-semibold">Linked Assets</h3>
<Button <Button
@@ -259,7 +247,7 @@
</TabsContent> </TabsContent>
<!-- References Tab --> <!-- References Tab -->
<TabsContent value="references" class="flex-1 p-6"> <TabsContent value="references" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3> <h3 class="text-sm font-semibold">Reference Files</h3>
<Popover v-if="canUploadReferences"> <Popover v-if="canUploadReferences">
@@ -295,7 +283,7 @@
</TabsContent> </TabsContent>
<!-- Design Tab --> <!-- Design Tab -->
<TabsContent value="design" class="flex-1 p-6"> <TabsContent value="design" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3> <h3 class="text-sm font-semibold">Design Information</h3>
<Button <Button
@@ -334,10 +322,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -346,11 +335,13 @@ import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue' import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue' import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot' import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task' import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl' import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission' import { usePermission } from '@/composables/usePermission'
@@ -375,7 +366,7 @@ interface Props {
interface Emits { interface Emits {
(e: 'edit', shot: Shot): void (e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void (e: 'delete', shot: Shot): void
(e: 'select-task', task: Task, tab?: string): void (e: 'select-task', task: Task, tab?: string, noteId?: number): void
(e: 'link-asset'): void (e: 'link-asset'): void
(e: 'edit-design'): void (e: 'edit-design'): void
(e: 'close'): void (e: 'close'): void
@@ -385,6 +376,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl() const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission() const { isAdmin, isCoordinatorOrAdmin } = usePermission()
@@ -395,6 +387,9 @@ const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isCreatingTask = ref(false) const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([]) const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const shotSubmissions = ref<Submission[]>([])
const isLoadingNotes = ref(false)
// Computed properties // Computed properties
const frameCount = computed(() => { const frameCount = computed(() => {
@@ -434,8 +429,6 @@ const taskStatusCounts = computed(() => {
const canCreateTask = computed(() => isCoordinatorOrAdmin.value) const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value) const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => { const canUploadReferences = computed(() => {
@@ -444,9 +437,15 @@ const canUploadReferences = computed(() => {
const canEditDesign = computed(() => isCoordinatorOrAdmin.value) const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
// Task types offered in "Add Task" include the flat shot task type list
// plus every task type owned by a shot department (e.g. Animation's own
// task types), deduped against types the shot already has a task for.
const availableTaskTypes = computed(() => { const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type)) const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type)) const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'shot')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
}) })
// Methods // Methods
@@ -455,11 +454,13 @@ const loadShotDetails = async () => {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId) shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([ await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId), taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers() loadProjectMembers()
]) ])
loadTasks() // No longer async - uses embedded data loadTasks() // No longer async - uses embedded data
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details' error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err) console.error('Failed to load shot details:', err)
@@ -468,6 +469,27 @@ const loadShotDetails = async () => {
} }
} }
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
shotSubmissions.value = []
return
}
try {
isLoadingNotes.value = true
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
shotNotes.value = notesByTask.flat()
shotSubmissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
const loadProjectMembers = async () => { const loadProjectMembers = async () => {
try { try {
projectMembers.value = await projectService.getProjectMembers(props.projectId) projectMembers.value = await projectService.getProjectMembers(props.projectId)
@@ -519,12 +541,6 @@ const handleAddTask = async (taskType: string) => {
} }
} }
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => { const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word => return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1) word.charAt(0).toUpperCase() + word.slice(1)
@@ -537,40 +553,6 @@ const getTaskAssigneeInitials = (task: Task) => {
return (first + last).toUpperCase() return (first + last).toUpperCase()
} }
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
@@ -1,5 +1,5 @@
<template> <template>
<div class="relative" <div class="relative flex items-center gap-1"
> >
<Select <Select
@@ -17,7 +17,7 @@
<!-- <TaskStatusBadge :status="currentStatusObject"/> --> <!-- <TaskStatusBadge :status="currentStatusObject"/> -->
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent @contextmenu.prevent>
<!-- System Statuses --> <!-- System Statuses -->
<SelectItem <SelectItem
v-for="statusOption in allStatusOptions" v-for="statusOption in allStatusOptions"
@@ -37,6 +37,112 @@
</SelectContent> </SelectContent>
</Select> </Select>
<!-- User Assignment Button -->
<div v-if="showAssignee" @click.stop>
<Popover>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-accent relative"
:disabled="isUpdating"
@click.stop="ensureMembersLoaded"
>
<Avatar class="h-4 w-4" v-if="assignedUser">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<User class="h-3 w-3" v-else />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
<div class="space-y-2">
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
<!-- Current Assignment Display with X button -->
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground">Current</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
@click.stop="handleAssignUser(null)"
:disabled="isAssigning"
>
<X class="h-3 w-3" />
</Button>
</div>
<!-- Search Input -->
<div class="relative">
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search members..."
class="pl-7 h-8 text-xs"
/>
</div>
<!-- Loading state -->
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2 text-sm">Loading members...</span>
</div>
<!-- Error state -->
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
No project members found
<Button
variant="outline"
size="sm"
class="mt-2"
@click="loadProjectMembers"
>
Retry
</Button>
</div>
<!-- Content when members are loaded -->
<template v-else>
<!-- Project members list -->
<div class="max-h-64 overflow-y-auto">
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
No matching members found
</div>
<Button
v-else
v-for="member in filteredProjectMembers"
:key="member.user_id"
variant="ghost"
size="sm"
class="w-full justify-start h-10"
@click="handleAssignUser(member.user_id)"
:disabled="isAssigning"
>
<Avatar class="h-8 w-8 mr-2">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
</div>
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Loading indicator --> <!-- Loading indicator -->
<div <div
v-if="isUpdating || isLoadingStatuses" v-if="isUpdating || isLoadingStatuses"
@@ -56,11 +162,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset' import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task' import { taskService } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus' import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import type { ProjectMember } from '@/services/project'
interface StatusOption { interface StatusOption {
id: string id: string
@@ -73,10 +191,13 @@ interface Props {
taskId: number taskId: number
status: TaskStatus | string status: TaskStatus | string
projectId: number projectId: number
showAssignee?: boolean
assignedUserId?: number | null
} }
interface Emits { interface Emits {
(e: 'status-updated', taskId: number, newStatus: string): void (e: 'status-updated', taskId: number, newStatus: string): void
(e: 'assignment-updated', taskId: number, userId: number | null): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls // Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const { getAvatarUrl } = useAvatarUrl()
const isUpdating = ref(false) const isUpdating = ref(false)
const isAssigning = ref(false)
const searchQuery = ref('')
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const filteredProjectMembers = computed(() => {
if (!searchQuery.value.trim()) {
return projectMembers.value
}
const query = searchQuery.value.toLowerCase().trim()
return projectMembers.value.filter(member => {
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
const departmentRole = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || departmentRole.includes(query)
})
})
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
const getUserInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const loadProjectMembers = async () => {
try {
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
}
}
const ensureMembersLoaded = () => {
if (projectMembers.value.length === 0) {
loadProjectMembers()
}
}
const handleAssignUser = async (userId: number | null) => {
isAssigning.value = true
try {
if (userId) {
await taskService.assignTask(props.taskId, userId)
} else {
await taskService.updateTask(props.taskId, { assigned_user_id: 0 })
}
emit('assignment-updated', props.taskId, userId)
// Close popover by simulating click outside after assignment
setTimeout(() => {
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}, 100)
} catch (error) {
console.error('Failed to assign task:', error)
} finally {
isAssigning.value = false
}
}
// Get loading state from store // Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId)) const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
+72 -37
View File
@@ -19,39 +19,50 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 space-y-1"> <div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2"> <!-- Message Card -->
<span class="font-semibold text-sm"> <div
{{ note.user_first_name }} {{ note.user_last_name }} class="rounded-2xl border px-3 py-2"
</span> :class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
<span class="text-xs text-muted-foreground"> >
{{ formatDateTime(note.created_at) }} <div class="flex items-center gap-2 flex-wrap">
</span> <span class="font-semibold text-sm">
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground"> {{ note.user_first_name }} {{ note.user_last_name }}
(edited) </span>
</span> <Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div> <span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
<Tooltip v-if="dateFormat === 'absolute'">
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(note.created_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
<!-- Note Content --> <!-- Note Content -->
<div v-if="!editing" class="text-sm whitespace-pre-wrap"> <div v-if="!editing" class="text-sm whitespace-pre-wrap mt-0.5">
{{ note.content }} {{ note.content }}
</div> </div>
<!-- Edit Form --> <!-- Edit Form -->
<div v-else class="space-y-2"> <div v-else class="space-y-2 mt-1">
<Textarea <Textarea
v-model="editContent" v-model="editContent"
rows="3" rows="3"
class="resize-none" class="resize-none bg-background"
/> />
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button> <Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button> <Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div> </div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div v-if="!editing" class="flex gap-2"> <div v-if="!editing" class="flex gap-2 px-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -63,30 +74,32 @@
<Button <Button
v-if="canEdit" v-if="canEdit"
variant="ghost" variant="ghost"
size="sm" size="icon-sm"
title="Edit"
@click="startEdit" @click="startEdit"
> >
<Pencil class="h-3 w-3 mr-1" /> <Pencil class="h-3.5 w-3.5" />
Edit
</Button> </Button>
<Button <Button
v-if="canDelete" v-if="canDelete"
variant="ghost" variant="ghost"
size="sm" size="icon-sm"
title="Delete"
@click="handleDelete" @click="handleDelete"
> >
<Trash2 class="h-3 w-3 mr-1" /> <Trash2 class="h-3.5 w-3.5" />
Delete
</Button> </Button>
</div> </div>
<!-- Child Notes (Threaded) --> <!-- Child Notes (Threaded) -->
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-4 space-y-4 pl-4 border-l-2"> <div v-if="note.child_notes && note.child_notes.length > 0" class="mt-3 space-y-3 pl-4 border-l-2">
<NoteItem <NoteItem
v-for="childNote in note.child_notes" v-for="childNote in note.child_notes"
:key="childNote.id" :key="childNote.id"
:note="childNote" :note="childNote"
:task-id="taskId" :task-id="taskId"
:date-format="dateFormat"
:hide-client-badge="hideClientBadge"
@note-updated="emit('noteUpdated')" @note-updated="emit('noteUpdated')"
@reply="emit('reply', $event)" @reply="emit('reply', $event)"
/> />
@@ -115,10 +128,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Reply, Pencil, Trash2 } from 'lucide-vue-next' import { Reply, Pencil, Trash2, Info } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -131,11 +146,14 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { taskService, type ProductionNote } from '@/services/task' import { taskService, type ProductionNote } from '@/services/task'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
note: ProductionNote note: ProductionNote
taskId: number taskId: number
dateFormat?: 'relative' | 'absolute'
hideClientBadge?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -145,25 +163,42 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
const { hasPermission } = usePermission()
const editing = ref(false) const editing = ref(false)
const editContent = ref('') const editContent = ref('')
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const isOwnNote = computed(() => authStore.user?.id === props.note.user_id)
const canEdit = computed(() => { const canEdit = computed(() => {
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin if (authStore.user?.is_admin) return true
return isOwnNote.value ? hasPermission('note', 'edit_self') : hasPermission('note', 'edit_other')
}) })
const canDelete = computed(() => { const canDelete = computed(() => {
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin if (authStore.user?.is_admin) return true
return isOwnNote.value ? hasPermission('note', 'delete_self') : hasPermission('note', 'delete_other')
}) })
function getInitials(firstName: string, lastName: string): string { function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
} }
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatDateTime(dateString: string): string { function formatDateTime(dateString: string): string {
const date = new Date(dateString) const date = new Date(dateString)
if (props.dateFormat === 'absolute') {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
const now = new Date() const now = new Date()
const diffMs = now.getTime() - date.getTime() const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000) const diffMins = Math.floor(diffMs / 60000)
+111 -12
View File
@@ -56,11 +56,19 @@
</div> </div>
</div> </div>
<div v-if="submission.notes" class="text-sm bg-muted p-2 rounded"> <div v-if="!editing && submission.notes" class="text-sm bg-muted p-2 rounded">
<p class="font-semibold text-xs mb-1">Notes:</p> <p class="font-semibold text-xs mb-1">Notes:</p>
<p class="line-clamp-2">{{ submission.notes }}</p> <p class="line-clamp-2">{{ submission.notes }}</p>
</div> </div>
<div v-if="editing" class="space-y-2">
<Textarea v-model="editNotes" rows="2" class="resize-none text-sm" placeholder="Notes about this submission..." />
<div class="flex gap-2">
<Button size="sm" @click="handleSaveNotes">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div>
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)"> <div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
<p class="font-semibold text-xs mb-1">Review Feedback:</p> <p class="font-semibold text-xs mb-1">Review Feedback:</p>
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p> <p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
@@ -69,37 +77,128 @@
</p> </p>
</div> </div>
<Button <div v-if="!editing" class="flex gap-2">
variant="outline" <Button
size="sm" variant="outline"
@click="emit('view', submission)" size="sm"
> @click="emit('view', submission)"
<Eye class="h-4 w-4 mr-2" /> >
View Details <Eye class="h-4 w-4 mr-2" />
</Button> View Details
</Button>
<Button v-if="canEdit" variant="ghost" size="sm" @click="startEdit">
<Pencil class="h-4 w-4 mr-2" />
Edit
</Button>
<Button v-if="canDelete" variant="ghost" size="sm" @click="showDeleteDialog = true">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div> </div>
</div> </div>
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Submission</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this submission? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next' import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import type { Submission } from '@/services/task' import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { taskService, type Submission } from '@/services/task'
import { apiClient } from '@/services/api' import { apiClient } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
taskId: number
submission: Submission submission: Submission
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
view: [submission: Submission] view: [submission: Submission]
submissionUpdated: []
}>() }>()
const { toast } = useToast()
const authStore = useAuthStore()
const { hasPermission } = usePermission()
const isOwnSubmission = computed(() => authStore.user?.id === props.submission.user_id)
const canEdit = computed(() => {
if (authStore.user?.is_admin) return true
return isOwnSubmission.value ? hasPermission('submission', 'edit_self') : hasPermission('submission', 'edit_other')
})
const canDelete = computed(() => {
if (authStore.user?.is_admin) return true
return isOwnSubmission.value ? hasPermission('submission', 'delete_self') : hasPermission('submission', 'delete_other')
})
const editing = ref(false)
const editNotes = ref('')
const showDeleteDialog = ref(false)
function startEdit() {
editing.value = true
editNotes.value = props.submission.notes || ''
}
async function handleSaveNotes() {
try {
await taskService.updateSubmission(props.taskId, props.submission.id, editNotes.value)
editing.value = false
emit('submissionUpdated')
toast({ title: 'Success', description: 'Submission updated successfully' })
} catch (error: any) {
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update submission',
variant: 'destructive'
})
}
}
async function handleDelete() {
try {
await taskService.deleteSubmission(props.taskId, props.submission.id)
emit('submissionUpdated')
toast({ title: 'Success', description: 'Submission deleted successfully' })
} catch (error: any) {
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to delete submission',
variant: 'destructive'
})
} finally {
showDeleteDialog.value = false
}
}
const thumbnailBlobUrl = ref<string | null>(null) const thumbnailBlobUrl = ref<string | null>(null)
function getFileExtension(filename: string): string { function getFileExtension(filename: string): string {
@@ -100,6 +100,7 @@ import { projectService, type ProjectMember } from '@/services/project'
import { shotService, type Shot } from '@/services/shot' import { shotService, type Shot } from '@/services/shot'
import { assetService, type Asset } from '@/services/asset' import { assetService, type Asset } from '@/services/asset'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel' import { useDetailPanel } from '@/composables/useDetailPanel'
interface Props { interface Props {
@@ -110,6 +111,7 @@ const props = defineProps<Props>()
const { toast } = useToast() const { toast } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
// Detail panel composable // Detail panel composable
const { const {
@@ -462,12 +464,28 @@ const loadColumnVisibility = () => {
} }
} }
const loadTaskStatuses = async () => {
if (!props.projectId) return
try {
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'
})
}
}
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
loadColumnVisibility() loadColumnVisibility()
fetchTasks() fetchTasks()
fetchEpisodes() fetchEpisodes()
fetchProjectMembers() fetchProjectMembers()
loadTaskStatuses()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -482,6 +500,7 @@ watch(
fetchTasks() fetchTasks()
fetchEpisodes() fetchEpisodes()
fetchProjectMembers() fetchProjectMembers()
loadTaskStatuses()
} }
) )
@@ -1,5 +1,9 @@
<template> <template>
<Popover v-model:open="isOpen"> <Popover v-model:open="isOpen">
<!-- pointer-events: none this is a positioning reference only. Without it, it's a
real (if invisible) 1x1px element sitting exactly at the original right-click
point, so a second right-click at the same spot hits *it* directly instead of
passing through to the row (or anything else) underneath. -->
<PopoverAnchor <PopoverAnchor
:style="{ :style="{
position: 'fixed', position: 'fixed',
@@ -7,6 +11,7 @@
top: `${props.position.y}px`, top: `${props.position.y}px`,
width: '1px', width: '1px',
height: '1px', height: '1px',
pointerEvents: 'none',
}" }"
/> />
<PopoverContent <PopoverContent
@@ -15,6 +20,10 @@
:align="'start'" :align="'start'"
@interact-outside="handleInteractOutside" @interact-outside="handleInteractOutside"
> >
<!-- Plain native div, not a component prop-forward chain guarantees the
listener actually reaches the DOM to suppress the browser's own menu
when right-clicking anywhere on this (already-a-right-click-triggered) menu. -->
<div @contextmenu.prevent>
<!-- Header showing selection count --> <!-- Header showing selection count -->
<div class="px-2 py-1.5 text-sm font-semibold text-muted-foreground border-b mb-1"> <div class="px-2 py-1.5 text-sm font-semibold text-muted-foreground border-b mb-1">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected {{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
@@ -37,51 +46,43 @@
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="w-48" side="right" align="start"> <DropdownMenuContent class="w-48" side="right" align="start">
<!-- Loading state --> <div @contextmenu.prevent>
<div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground"> <!-- Loading state -->
Loading statuses... <div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground">
Loading statuses...
</div>
<!-- System statuses -->
<template v-else>
<div v-if="systemStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
System Statuses
</div>
<DropdownMenuItem
v-for="status in systemStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
<!-- Divider if both system and custom statuses exist -->
<div v-if="systemStatuses.length > 0 && customStatuses.length > 0" class="h-px bg-border my-1" />
<!-- Custom statuses -->
<div v-if="customStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Custom Statuses
</div>
<DropdownMenuItem
v-for="status in customStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
</template>
</div> </div>
<!-- System statuses -->
<template v-else>
<div v-if="systemStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
System Statuses
</div>
<DropdownMenuItem
v-for="status in systemStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
class="flex items-center gap-2"
>
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: status.color }"
/>
<span>{{ status.name }}</span>
</DropdownMenuItem>
<!-- Divider if both system and custom statuses exist -->
<div v-if="systemStatuses.length > 0 && customStatuses.length > 0" class="h-px bg-border my-1" />
<!-- Custom statuses -->
<div v-if="customStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Custom Statuses
</div>
<DropdownMenuItem
v-for="status in customStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
class="flex items-center gap-2"
>
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: status.color }"
/>
<span>{{ status.name }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -89,7 +90,7 @@
<div class="h-px bg-border my-1" /> <div class="h-px bg-border my-1" />
<!-- Assign To submenu --> <!-- Assign To submenu -->
<DropdownMenu> <DropdownMenu @update:open="onAssignMenuOpenChange">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<button <button
:disabled="isProcessing || hasMultipleProjects" :disabled="isProcessing || hasMultipleProjects"
@@ -99,20 +100,73 @@
<ChevronRight class="h-4 w-4" /> <ChevronRight class="h-4 w-4" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="w-48 max-h-48 overflow-y-auto" side="right" align="start"> <DropdownMenuContent class="w-56 p-0" side="right" align="start">
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground"> <div @contextmenu.prevent>
No members available <div class="p-1.5 border-b" @keydown.stop @click.stop>
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="assigneeSearchQuery"
placeholder="Search members..."
class="h-7 pl-7 text-xs"
/>
</div>
</div>
<div class="max-h-56 overflow-y-auto p-1">
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No members available
</div>
<div v-else-if="filteredMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No matching members
</div>
<template v-else>
<div v-if="recommendedMembers.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Recommended
</div>
<DropdownMenuItem
v-for="member in recommendedMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
class="flex items-center gap-2"
>
<Avatar class="h-6 w-6 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getMemberInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col min-w-0">
<span class="truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span v-if="member.department_role" class="text-[10px] text-muted-foreground capitalize">{{ member.department_role }}</span>
</div>
</DropdownMenuItem>
<div v-if="recommendedMembers.length > 0 && otherMembers.length > 0" class="h-px bg-border my-1" />
<div v-if="otherMembers.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
{{ recommendedMembers.length > 0 ? 'All Members' : 'Members' }}
</div>
<DropdownMenuItem
v-for="member in otherMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
class="flex items-center gap-2"
>
<Avatar class="h-6 w-6 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getMemberInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col min-w-0">
<span class="truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span v-if="member.department_role" class="text-[10px] text-muted-foreground capitalize">{{ member.department_role }}</span>
</div>
</DropdownMenuItem>
</template>
</div>
</div> </div>
<DropdownMenuItem
v-for="member in projectMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
>
{{ member.user_first_name }} {{ member.user_last_name }}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</template> </template>
@@ -130,11 +184,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ChevronRight } from 'lucide-vue-next' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Input } from '@/components/ui/input'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { ChevronRight, Search } from 'lucide-vue-next'
import type { ProjectMember } from '@/services/project' import type { ProjectMember } from '@/services/project'
import type { Task } from '@/services/task' import type { Task } from '@/services/task'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus' import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface Props { interface Props {
open: boolean open: boolean
@@ -159,10 +217,14 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store // Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const { getAvatarUrl } = useAvatarUrl()
// Local state for menu open/close // Local state for menu open/close
const isOpen = ref(props.open) const isOpen = ref(props.open)
// Assign To search state
const assigneeSearchQuery = ref('')
// Computed properties // Computed properties
const hasMultipleProjects = computed(() => { const hasMultipleProjects = computed(() => {
if (props.selectedTasks.length === 0) return false if (props.selectedTasks.length === 0) return false
@@ -193,6 +255,54 @@ const customStatuses = computed(() => {
return statuses?.statuses || [] return statuses?.statuses || []
}) })
// Task types among the selected tasks, used to recommend project members whose
// department matches. "composite" (department) vs. "compositing" (task type) is
// the one known vocabulary mismatch, handled explicitly below.
const selectedTaskTypes = computed(() => new Set(props.selectedTasks.map(task => task.task_type)))
const departmentMatchesSelection = (departmentRole: string | undefined | null): boolean => {
if (!departmentRole) return false
for (const taskType of selectedTaskTypes.value) {
if (departmentRole === taskType) return true
if (departmentRole === 'composite' && taskType === 'compositing') return true
}
return false
}
const filteredMembers = computed(() => {
const query = assigneeSearchQuery.value.toLowerCase().trim()
if (!query) return props.projectMembers
return props.projectMembers.filter(member => {
const fullName = `${member.user_first_name} ${member.user_last_name}`.toLowerCase()
const department = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || department.includes(query)
})
})
// Members whose department matches the selected task(s) are surfaced first, but
// everyone remains selectable department_role is often unset, and a hard filter
// would leave the list empty for many projects.
const recommendedMembers = computed(() =>
filteredMembers.value.filter(member => departmentMatchesSelection(member.department_role))
)
const otherMembers = computed(() =>
filteredMembers.value.filter(member => !departmentMatchesSelection(member.department_role))
)
const getMemberInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const onAssignMenuOpenChange = (open: boolean) => {
if (!open) {
assigneeSearchQuery.value = ''
}
}
// Methods // Methods
const fetchStatuses = async () => { const fetchStatuses = async () => {
if (!currentProjectId.value || hasMultipleProjects.value) { if (!currentProjectId.value || hasMultipleProjects.value) {
+223 -39
View File
@@ -12,31 +12,39 @@
<!-- Task Details --> <!-- Task Details -->
<div v-else-if="task" class="flex-1 flex flex-col min-h-0"> <div v-else-if="task" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')"> <DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
<template #badges>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</template>
</DetailPanelHeader> </DetailPanelHeader>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0"> <Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
<!-- Tabs List (Fixed) --> <!-- Tabs List (Fixed) -->
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger> <TabsTrigger value="infos" title="Infos">
<TabsTrigger value="notes"> <Info class="h-4 w-4" />
Notes <span class="sr-only">Infos</span>
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="attachments"> <TabsTrigger value="notes" title="Notes">
Attachments <span class="relative inline-flex">
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2"> <MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="attachments" title="Attachments">
<Paperclip class="h-4 w-4" />
<span class="sr-only">Attachments</span>
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ attachments.length }} {{ attachments.length }}
</Badge> </Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="submissions"> <TabsTrigger value="submissions" title="Submissions">
Submissions <Upload class="h-4 w-4" />
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2"> <span class="sr-only">Submissions</span>
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ submissions.length }} {{ submissions.length }}
</Badge> </Badge>
</TabsTrigger> </TabsTrigger>
@@ -111,16 +119,54 @@
<div class="grid grid-cols-2 gap-4 text-xs"> <div class="grid grid-cols-2 gap-4 text-xs">
<div> <div>
<Label class="text-muted-foreground">Type</Label> <Label class="text-muted-foreground">Type</Label>
<p class="text-sm mt-1"> <div class="mt-1">
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge> <Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
</p> <SelectTrigger class="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="taskType in taskTypeOptions" :key="taskType" :value="taskType">
{{ formatTaskType(taskType) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label class="text-muted-foreground">Department</Label>
<div class="mt-1">
<Select :model-value="localDepartment || 'none'" @update:model-value="(value) => handleDepartmentChange(value === 'none' ? '' : (value as string))">
<SelectTrigger class="h-8">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="department in departmentOptions" :key="department" :value="department">
{{ formatDepartment(department) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label class="text-muted-foreground">Start Date</Label>
<div class="mt-1">
<DatePicker
v-model="localStartDate"
placeholder="Set start date"
@update:model-value="(val) => handleDateChange('start_date', val || '')"
/>
</div>
</div> </div>
<div> <div>
<Label class="text-muted-foreground">Deadline</Label> <Label class="text-muted-foreground">Deadline</Label>
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)"> <div class="mt-1">
<Calendar class="h-3 w-3" /> <DatePicker
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }} v-model="localDeadline"
</p> placeholder="Set deadline"
@update:model-value="(val) => handleDateChange('deadline', val || '')"
/>
</div>
</div> </div>
</div> </div>
<div> <div>
@@ -178,7 +224,12 @@
<!-- Notes Tab --> <!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden"> <TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" /> <TaskNotes
:task-id="taskId"
:notes="notes"
:initial-reply-note-id="initialReplyNoteId"
@notes-updated="loadNotes"
/>
</TabsContent> </TabsContent>
<!-- Attachments Tab --> <!-- Attachments Tab -->
@@ -188,7 +239,16 @@
<!-- Submissions Tab --> <!-- Submissions Tab -->
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden"> <TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" /> <TaskSubmissions
:task-id="taskId"
:submissions="submissions"
:task-type="task?.task_type"
:project-id="task?.project_id"
:name="task?.shot_name || task?.asset_name"
:task-name="task?.name"
:project-name="task?.project_name"
@submissions-updated="loadSubmissions"
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -262,10 +322,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next' import { Play, Upload, UserPlus, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { DatePicker } from '@/components/ui/date-picker'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue' import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue' import DetailPanelError from '@/components/shared/DetailPanelError.vue'
@@ -299,18 +360,20 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from '@/components/ui/command' } from '@/components/ui/command'
import TaskStatusBadge from './TaskStatusBadge.vue'
import TaskNotes from './TaskNotes.vue' import TaskNotes from './TaskNotes.vue'
import TaskAttachments from './TaskAttachments.vue' import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue' import TaskSubmissions from './TaskSubmissions.vue'
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task' import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import { useDepartmentsStore } from '@/stores/departments'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
initialTab?: string initialTab?: string
initialReplyNoteId?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -321,11 +384,17 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission() const { isCoordinatorOrAdmin } = usePermission()
const departmentsStore = useDepartmentsStore()
const task = ref<Task | null>(null) const task = ref<Task | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const localStatus = ref('') const localStatus = ref('')
const localStartDate = ref('')
const localDeadline = ref('')
const localDepartment = ref('')
const flatShotTaskTypes = ref<string[]>([])
const flatAssetTaskTypes = ref<string[]>([])
const notes = ref<ProductionNote[]>([]) const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([]) const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
@@ -353,12 +422,48 @@ const canSubmitWork = computed(() => {
const canReassign = computed(() => isCoordinatorOrAdmin.value) const canReassign = computed(() => isCoordinatorOrAdmin.value)
// Departments are type-scoped (shot vs asset); a standalone task (neither
// shot nor asset) falls back to the unfiltered list.
const departmentOptions = computed(() => {
if (!task.value) return []
if (task.value.shot_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'shot').map(d => d.name)
if (task.value.asset_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'asset').map(d => d.name)
return departmentsStore.getAllDepartmentOptions(task.value.project_id)
})
// Task Type options come from the current department's owned task types when
// it has any; otherwise fall back to the existing flat asset/shot task type list.
const taskTypeOptions = computed(() => {
if (!task.value) return []
const departmentTaskTypes = localDepartment.value
? departmentsStore.getDepartmentTaskTypes(task.value.project_id, localDepartment.value)
: []
if (departmentTaskTypes.length > 0) return departmentTaskTypes
const flatTypes = task.value.shot_id ? flatShotTaskTypes.value : flatAssetTaskTypes.value
// Always include the task's current type, even if it isn't in either list
// (e.g. a legacy or since-removed value), so the Select never shows blank.
return flatTypes.includes(task.value.task_type) ? flatTypes : [task.value.task_type, ...flatTypes]
})
function formatDepartment(department: string): string {
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}
async function loadTask() { async function loadTask() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
task.value = await taskService.getTask(props.taskId) task.value = await taskService.getTask(props.taskId)
localStatus.value = task.value.status localStatus.value = task.value.status
localStartDate.value = task.value.start_date || ''
localDeadline.value = task.value.deadline || ''
localDepartment.value = task.value.department || ''
departmentsStore.fetchProjectDepartments(task.value.project_id)
customTaskTypeService.getAllTaskTypes(task.value.project_id).then(types => {
flatShotTaskTypes.value = types.shot_task_types
flatAssetTaskTypes.value = types.asset_task_types
}).catch(err => console.error('Failed to load task types:', err))
} catch (err: any) { } catch (err: any) {
console.error('Error loading task:', err) console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task' error.value = err.response?.data?.detail || 'Failed to load task'
@@ -418,6 +523,98 @@ async function handleStatusChange(newStatus: string) {
} }
} }
async function handleDateChange(field: 'start_date' | 'deadline', value: string) {
if (!task.value) return
const previous = task.value[field]
try {
const updated = await taskService.updateTask(props.taskId, { [field]: value || null } as any)
task.value[field] = updated[field]
emit('taskUpdated')
toast({
title: 'Success',
description: `Task ${field === 'start_date' ? 'start date' : 'deadline'} updated successfully`
})
} catch (error: any) {
console.error(`Error updating ${field}:`, error)
if (field === 'start_date') localStartDate.value = previous || ''
else localDeadline.value = previous || ''
toast({
title: 'Error',
description: error.response?.data?.detail || `Failed to update ${field === 'start_date' ? 'start date' : 'deadline'}`,
variant: 'destructive'
})
}
}
async function handleDepartmentChange(value: string) {
if (!task.value) return
const previousDepartment = task.value.department
const previousTaskType = task.value.task_type
// If the current task type doesn't belong to the newly-picked department
// (and that department has its own task types), reset to its first one so
// department and task type stay consistent.
const newDepartmentTaskTypes = value ? departmentsStore.getDepartmentTaskTypes(task.value.project_id, value) : []
const needsTaskTypeReset = newDepartmentTaskTypes.length > 0 && !newDepartmentTaskTypes.includes(task.value.task_type)
const payload: Record<string, any> = { department: value || null }
if (needsTaskTypeReset) {
payload.task_type = newDepartmentTaskTypes[0]
}
try {
const updated = await taskService.updateTask(props.taskId, payload as any)
task.value.department = updated.department
task.value.task_type = updated.task_type
localDepartment.value = updated.department || ''
emit('taskUpdated')
toast({
title: 'Success',
description: 'Task department updated successfully'
})
} catch (error: any) {
console.error('Error updating department:', error)
localDepartment.value = previousDepartment || ''
task.value.task_type = previousTaskType
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update task department',
variant: 'destructive'
})
}
}
async function handleTaskTypeChange(value: string) {
if (!task.value || value === task.value.task_type) return
const previousTaskType = task.value.task_type
const previousDepartment = task.value.department
try {
const updated = await taskService.updateTask(props.taskId, { task_type: value } as any)
task.value.task_type = updated.task_type
task.value.department = updated.department
localDepartment.value = updated.department || ''
emit('taskUpdated')
toast({
title: 'Success',
description: 'Task type updated successfully'
})
} catch (error: any) {
console.error('Error updating task type:', error)
task.value.task_type = previousTaskType
task.value.department = previousDepartment
localDepartment.value = previousDepartment || ''
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update task type',
variant: 'destructive'
})
}
}
async function handleQuickAction(action: 'start' | 'submit') { async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return if (!task.value) return
@@ -497,19 +694,6 @@ function formatDate(dateString: string): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} }
function getDeadlineClass(deadline: string | undefined, status: string): string {
if (!deadline || status === 'approved') return 'text-muted-foreground'
const now = new Date()
const deadlineDate = new Date(deadline)
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntil < 0) return 'text-destructive'
if (daysUntil <= 3) return 'text-orange-600'
if (daysUntil <= 7) return 'text-yellow-600'
return 'text-foreground'
}
watch(() => props.taskId, () => { watch(() => props.taskId, () => {
loadTask() loadTask()
loadNotes() loadNotes()
+74 -12
View File
@@ -20,13 +20,42 @@
<!-- Note Input (Bottom) --> <!-- Note Input (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2"> <div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2"> <div class="space-y-2">
<Textarea <div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
v-model="newNoteContent" <span class="truncate">
placeholder="Add a note..." Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
rows="2" <span class="text-muted-foreground"> {{ replyToNote.content }}</span>
class="resize-none text-sm" </span>
/> <button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<div class="flex justify-end"> <X class="h-3 w-3" />
</button>
</div>
<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 <Button
@click="handleAddNote" @click="handleAddNote"
:disabled="!newNoteContent.trim() || submitting" :disabled="!newNoteContent.trim() || submitting"
@@ -42,17 +71,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import { MessageSquarePlus } from 'lucide-vue-next' import { MessageSquarePlus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import NoteItem from './NoteItem.vue' import NoteItem from './NoteItem.vue'
import { taskService, type ProductionNote } from '@/services/task' import { taskService, type ProductionNote, type NoteType } from '@/services/task'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
notes: ProductionNote[] notes: ProductionNote[]
initialReplyNoteId?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -62,8 +92,26 @@ const emit = defineEmits<{
const { toast } = useToast() const { toast } = useToast()
const newNoteContent = ref('') const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const submitting = ref(false) const submitting = ref(false)
const replyToNoteId = ref<number | null>(null) const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
async function handleAddNote() { async function handleAddNote() {
if (!newNoteContent.value.trim()) return if (!newNoteContent.value.trim()) return
@@ -73,9 +121,11 @@ async function handleAddNote() {
await taskService.createTaskNote( await taskService.createTaskNote(
props.taskId, props.taskId,
newNoteContent.value, newNoteContent.value,
replyToNoteId.value || undefined replyToNoteId.value || undefined,
newNoteType.value
) )
newNoteContent.value = '' newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null replyToNoteId.value = null
emit('notesUpdated') emit('notesUpdated')
toast({ toast({
@@ -96,6 +146,18 @@ async function handleAddNote() {
function handleReply(noteId: number) { function handleReply(noteId: number) {
replyToNoteId.value = noteId replyToNoteId.value = noteId
// Focus on textarea (you could add a ref for this) nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
} }
function cancelReply() {
replyToNoteId.value = null
}
onMounted(() => {
if (props.initialReplyNoteId) {
handleReply(props.initialReplyNoteId)
}
})
</script> </script>
@@ -11,8 +11,10 @@
<SubmissionCard <SubmissionCard
v-for="submission in submissions" v-for="submission in submissions"
:key="submission.id" :key="submission.id"
:task-id="taskId"
:submission="submission" :submission="submission"
@view="handleView" @view="handleView"
@submission-updated="emit('submissionsUpdated')"
/> />
</div> </div>
</div> </div>
@@ -20,6 +22,26 @@
<!-- Submit Work Form (Bottom) --> <!-- Submit Work Form (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2"> <div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2"> <div class="space-y-2">
<div v-if="submissionConfig" class="text-xs text-muted-foreground space-y-0.5 px-1">
<div class="flex items-center gap-1.5">
<span v-if="submissionConfig.allowed_extensions.length > 0">
Allowed: {{ submissionConfig.allowed_extensions.join(', ') }}
</span>
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
</div>
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
{{ submissionConfig.naming_pattern_is_regex ? 'Naming (regex):' : 'Naming:' }}
<code>{{ submissionConfig.naming_pattern }}</code>
</div>
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
Movie spec:
<span v-if="submissionConfig.movie_resolution">{{ submissionConfig.movie_resolution }}</span>
<span v-if="submissionConfig.movie_format">.{{ submissionConfig.movie_format }}</span>
<span v-if="submissionConfig.movie_codec">({{ submissionConfig.movie_codec }})</span>
<span v-if="submissionConfig.movie_frame_rate">{{ submissionConfig.movie_frame_rate }}fps</span>
</div>
</div>
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors"> <div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
<input <input
ref="fileInput" ref="fileInput"
@@ -108,7 +130,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted, watch } from 'vue'
import { Upload } from 'lucide-vue-next' import { Upload } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -122,12 +144,20 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import SubmissionCard from './SubmissionCard.vue' import SubmissionCard from './SubmissionCard.vue'
import { taskService, type Submission } from '@/services/task' import { taskService, type Submission } from '@/services/task'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { apiClient } from '@/services/api' import { apiClient } from '@/services/api'
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
submissions: Submission[] submissions: Submission[]
taskType?: string
projectId?: number
name?: string
taskName?: string
projectName?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -142,12 +172,213 @@ const submissionNotes = ref('')
const viewerOpen = ref(false) const viewerOpen = ref(false)
const selectedSubmission = ref<Submission | null>(null) const selectedSubmission = ref<Submission | null>(null)
const mediaBlobUrl = ref<string | null>(null) const mediaBlobUrl = ref<string | null>(null)
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
const projectCode = ref<string | null>(null)
async function loadSubmissionConfig() {
submissionConfig.value = null
projectCode.value = null
if (!props.projectId || !props.taskType) return
try {
const config = await projectService.getProjectSubmissionConfig(props.projectId)
submissionConfig.value = config.config_by_task_type[props.taskType] || null
if (submissionConfig.value?.naming_pattern?.includes('{project_code}')) {
const project = await projectService.getProject(props.projectId)
projectCode.value = project.code_name
}
} catch (error) {
console.error('Failed to load submission configuration:', error)
}
}
onMounted(loadSubmissionConfig)
watch(() => [props.projectId, props.taskType], loadSubmissionConfig)
const MOVIE_EXTENSIONS = ['.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf']
let mediaInfoPromise: ReturnType<typeof mediaInfoFactory> | null = null
function getMediaInfo() {
if (!mediaInfoPromise) {
mediaInfoPromise = mediaInfoFactory({ locateFile: () => mediaInfoWasmUrl })
}
return mediaInfoPromise
}
interface VideoTrackInfo {
width: number | null
height: number | null
format: string | null
codecId: string | null
formatCommercial: string | null
frameRate: number | null
}
// Parses container/codec metadata directly from the file's bytes (no playback) - works for
// containers browsers can't play natively, e.g. MXF, or codecs like ProRes/DNxHD
async function analyzeVideoFile(file: File): Promise<VideoTrackInfo | null> {
try {
const mediainfo = await getMediaInfo()
const getSize = () => file.size
const readChunk = (chunkSize: number, offset: number) =>
new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
})
const result = await mediainfo.analyzeData(getSize, readChunk)
const videoTrack = result.media?.track.find(t => isTrackType(t, 'Video'))
if (!videoTrack) return null
const track = videoTrack as unknown as Record<string, unknown>
const commercial = [track.Format_Commercial, track.Format_Commercial_IfAny]
.filter((v): v is string => typeof v === 'string')
.join(' ')
return {
width: videoTrack.Width ?? null,
height: videoTrack.Height ?? null,
format: videoTrack.Format ?? null,
codecId: videoTrack.CodecID ?? null,
formatCommercial: commercial || null,
frameRate: videoTrack.FrameRate ?? null
}
} catch (error) {
console.error('Failed to analyze video file:', error)
return null
}
}
const CODEC_PATTERNS: Record<string, RegExp> = {
h264: /\b(avc|h\.?264)\b/i,
h265: /\b(hevc|h\.?265)\b/i,
mjpeg: /\bm?jpeg\b/i,
dnxhd: /\bdnxhd\b/i,
dnxhr: /\bdnxhr\b/i,
prores: /\bprores\b/i,
uncompressed: /\b(uncompressed|raw)\b/i,
avid: /\bavid\b/i,
cineform: /\bcineform\b/i
}
function normalizeCodec(info: VideoTrackInfo): string | null {
const haystack = [info.format, info.formatCommercial, info.codecId].filter(Boolean).join(' ')
for (const [key, pattern] of Object.entries(CODEC_PATTERNS)) {
if (pattern.test(haystack)) return key
}
return null
}
// All checking happens client-side, entirely in the browser, before the file is ever uploaded
async function findSubmissionViolation(file: File): Promise<string | null> {
const config = submissionConfig.value
if (!config) return null
const filename = file.name
const dotIndex = filename.lastIndexOf('.')
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
const extension = dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ''
if (config.allowed_extensions.length > 0 && !config.allowed_extensions.includes(extension)) {
return `File type '${extension}' is not accepted for ${props.taskType} submissions. Allowed types: ${config.allowed_extensions.join(', ')}`
}
if (config.check_naming && config.naming_pattern) {
if (config.naming_pattern_is_regex) {
try {
const regex = new RegExp(config.naming_pattern)
if (!regex.test(stem)) {
return `Filename does not match the required pattern '${config.naming_pattern}'`
}
} catch (error) {
console.error('Invalid naming pattern regex:', error)
}
} else {
const tokenValues: Record<string, string | undefined> = {
name: props.name,
task_type: props.taskType,
task_name: props.taskName,
project_name: props.projectName,
project_code: projectCode.value || undefined
}
const usedTokens = config.naming_pattern.match(/\{(name|task_name|project_name|project_code)\}/g) || []
const unresolvable = usedTokens.some(token => !tokenValues[token.slice(1, -1)])
if (!unresolvable) {
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = config.naming_pattern
.split(/(\{name\}|\{task_type\}|\{task_name\}|\{project_name\}|\{project_code\}|\{version\})/)
.map(part => {
if (part === '{version}') return '\\d{3}'
const key = part.startsWith('{') && part.endsWith('}') ? part.slice(1, -1) : null
if (key && key in tokenValues) return escapeRegex(tokenValues[key] || '')
return escapeRegex(part)
})
.join('')
const regex = new RegExp(`^${pattern}$`)
if (!regex.test(stem)) {
const example = config.naming_pattern
.replace('{name}', props.name || 'name')
.replace('{task_name}', props.taskName || 'taskname')
.replace('{project_name}', props.projectName || 'project')
.replace('{project_code}', projectCode.value || 'CODE')
.replace('{task_type}', props.taskType || '')
.replace('{version}', '001')
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
}
}
// If a token couldn't be resolved client-side, don't false-block
}
}
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
return `Movie format '${extension}' does not match the required format '.${config.movie_format}'`
}
if (config.movie_resolution || config.movie_codec || config.movie_frame_rate) {
const info = await analyzeVideoFile(file)
if (info) {
if (config.movie_resolution && info.width && info.height) {
const actual = `${info.width}x${info.height}`
if (actual !== config.movie_resolution) {
return `Video resolution ${actual} does not match the required resolution ${config.movie_resolution}`
}
}
if (config.movie_codec) {
const actualCodec = normalizeCodec(info)
if (actualCodec && actualCodec !== config.movie_codec) {
return `Video codec '${actualCodec}' does not match the required codec '${config.movie_codec}'`
}
// If the codec couldn't be identified, don't false-block
}
if (config.movie_frame_rate && info.frameRate) {
if (Math.abs(info.frameRate - config.movie_frame_rate) > 0.05) {
return `Video frame rate ${info.frameRate}fps does not match the required frame rate ${config.movie_frame_rate}fps`
}
}
}
// If the file couldn't be analyzed at all, don't false-block
}
}
return null
}
async function handleFileSelect(event: Event) { async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
const file = target.files?.[0] const file = target.files?.[0]
if (!file) return if (!file) return
const violation = await findSubmissionViolation(file)
if (violation) {
toast({
title: 'Invalid submission',
description: violation,
variant: 'destructive'
})
if (fileInput.value) {
fileInput.value.value = ''
}
return
}
uploading.value = true uploading.value = true
try { try {
await taskService.submitWork( await taskService.submitWork(
@@ -1,6 +1,6 @@
<template> <template>
<div class="px-4 h-full flex flex-col"> <div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden"> <div class="rounded-md border flex-1 min-h-0 overflow-hidden" @contextmenu.prevent>
<Table> <Table>
<TableHeader class="sticky top-0 z-10 bg-background"> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
@@ -88,6 +88,7 @@ import {
import { createColumns } from './columns' import { createColumns } from './columns'
import { type Task } from '@/services/task' import { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset' import { TaskStatus } from '@/services/asset'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
// Props interface // Props interface
interface Props { interface Props {
@@ -112,6 +113,8 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore()
// Internal state // Internal state
const sorting = ref<SortingState>([{ id: 'created_at', desc: true }]) const sorting = ref<SortingState>([{ id: 'created_at', desc: true }])
const rowSelection = ref<RowSelectionState>({}) const rowSelection = ref<RowSelectionState>({})
@@ -141,6 +144,14 @@ const columns = createColumns({
// row-double-click actually opens the panel, so reuse that instead. // row-double-click actually opens the panel, so reuse that instead.
onViewDetails: (task: Task) => emit('row-double-click', task), onViewDetails: (task: Task) => emit('row-double-click', task),
onReassign: (task: Task) => emit('row-double-click', task), onReassign: (task: Task) => emit('row-double-click', task),
// Real per-project statuses (system + custom), matching EditableTaskStatus's cell
// rendering. Scoped to the selected rows' project (falls back to props.projectId,
// which is 0/unset for cross-project lists like My Tasks with no selection yet).
getAllStatusOptions: () => {
const selected = getSelectedTasks()
const projectId = selected.length > 0 ? selected[0].project_id : props.projectId
return projectId ? taskStatusesStore.getAllStatusOptions(projectId) : []
},
}) })
// TanStack Table configuration // TanStack Table configuration
+20 -7
View File
@@ -15,10 +15,11 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue' import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { type Task } from '@/services/task' import { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset' import { TaskStatus } from '@/services/asset'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString) const date = new Date(dateString)
@@ -35,6 +36,10 @@ interface ColumnCallbacks {
getSelectedCount?: () => number getSelectedCount?: () => number
onViewDetails?: (task: Task) => void onViewDetails?: (task: Task) => void
onReassign?: (task: Task) => void onReassign?: (task: Task) => void
// Real per-project status options (system + custom), matching what EditableTaskStatus
// shows in the cell — falls back to [] if the relevant project's statuses aren't
// loaded yet.
getAllStatusOptions?: () => Array<CustomTaskStatus | SystemTaskStatus>
} }
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => { export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
@@ -150,10 +155,14 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
}), }),
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, { h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
default: () => { default: () => {
const allStatusOptions = callbacks?.getAllStatusOptions?.() || []
return h('div', { class: 'flex flex-col gap-1' }, [ // Plain native onContextmenu (not relying on the .prevent modifier
// reaching through PopoverContent's attrs-forwarding) so right-clicking
// this popover doesn't fall through to the browser's own menu.
return h('div', { class: 'flex flex-col gap-1', onContextmenu: (e: Event) => e.preventDefault() }, [
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change Status`), h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change Status`),
...Object.values(TaskStatus).map((status) => ...allStatusOptions.map((statusOption) =>
h( h(
Button, Button,
{ {
@@ -161,11 +170,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
size: 'sm', size: 'sm',
class: 'justify-start', class: 'justify-start',
onClick: () => { onClick: () => {
callbacks?.onBulkStatusChange?.(status) callbacks?.onBulkStatusChange?.(statusOption.id as TaskStatus)
isPopoverOpen.value = false isPopoverOpen.value = false
}, },
}, },
() => h(TaskStatusBadge, { status, class: 'w-full' }) () => h(TaskStatusBadge, { status: statusOption, compact: true, class: 'w-full' })
) )
), ),
]) ])
@@ -352,7 +361,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
DropdownMenuContent, DropdownMenuContent,
{ align: 'end' }, { align: 'end' },
{ {
default: () => [ // Native div wrapper (not a prop on DropdownMenuContent itself) — that
// component doesn't explicitly forward arbitrary attrs the way
// PopoverContent does, so a listener placed directly on it isn't
// guaranteed to reach the real DOM element.
default: () => h('div', { onContextmenu: (e: Event) => e.preventDefault() }, [
h( h(
DropdownMenuItem, DropdownMenuItem,
{ {
@@ -385,7 +398,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
], ],
} }
), ),
], ]),
} }
), ),
], ],
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
props.class, props.class,
)" )"
> >
<span class="truncate"> <span class="inline-flex items-center justify-center">
<slot /> <slot />
</span> </span>
</TabsTrigger> </TabsTrigger>
@@ -1,21 +1,33 @@
<template> <template>
<textarea <textarea
v-model="modelValue"
:class="cn( :class="cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class props.class
)" )"
v-bind="$attrs"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface Props { interface Props {
class?: string class?: string
modelValue?: string
defaultValue?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
class: '' class: ''
}) })
const emits = defineEmits<{
(e: 'update:modelValue', payload: string): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script> </script>
@@ -114,7 +114,10 @@
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline">{{ formatRole(user.role) }}</Badge> <div class="flex flex-wrap items-center gap-1">
<Badge variant="outline">{{ formatRole(user.role) }}</Badge>
<Badge v-for="role in user.roles" :key="role.id" variant="secondary">{{ role.name }}</Badge>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge v-if="user.is_admin" variant="destructive">Admin</Badge> <Badge v-if="user.is_admin" variant="destructive">Admin</Badge>
@@ -144,6 +147,10 @@
<Key class="h-4 w-4 mr-2" /> <Key class="h-4 w-4 mr-2" />
Reset Password Reset Password
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="handleManageRoles(user)">
<ShieldCheck class="h-4 w-4 mr-2" />
Manage Roles
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
v-if="!user.is_approved" v-if="!user.is_approved"
@@ -216,7 +223,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown } from "lucide-vue-next"; import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown, ShieldCheck } from "lucide-vue-next";
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
interface Props { interface Props {
@@ -232,6 +239,7 @@ interface Emits {
(e: "approveUser", userId: number): void; (e: "approveUser", userId: number): void;
(e: "editUser", user: User): void; (e: "editUser", user: User): void;
(e: "resetPassword", user: User): void; (e: "resetPassword", user: User): void;
(e: "manageRoles", user: User): void;
(e: "deleteUser", user: User): void; (e: "deleteUser", user: User): void;
} }
@@ -378,6 +386,10 @@ const handleResetPassword = (user: User) => {
emit("resetPassword", user); emit("resetPassword", user);
}; };
const handleManageRoles = (user: User) => {
emit("manageRoles", user);
};
const handleDeleteUser = (user: User) => { const handleDeleteUser = (user: User) => {
emit("deleteUser", user); emit("deleteUser", user);
}; };
@@ -0,0 +1,74 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Manage Roles</DialogTitle>
<DialogDescription>
{{ user ? `${user.first_name} ${user.last_name}` : '' }} assign custom roles in addition to their base role
</DialogDescription>
</DialogHeader>
<div class="space-y-2 max-h-72 overflow-auto">
<label
v-for="role in roles"
:key="role.id"
class="flex items-center gap-2 rounded-md p-2 hover:bg-accent cursor-pointer"
>
<Checkbox
:model-value="selectedRoleIds.has(role.id)"
@update:model-value="(val) => toggleRole(role.id, !!val)"
/>
<span class="flex-1">{{ role.name }}</span>
<Badge :variant="role.is_system ? 'secondary' : 'outline'">{{ role.is_system ? 'System' : 'Custom' }}</Badge>
</label>
<p v-if="roles.length === 0" class="text-sm text-muted-foreground p-2">No roles available.</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
<Button :disabled="saving" @click="handleSave">{{ saving ? 'Saving...' : 'Save' }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import type { Role } from '@/services/role'
import type { User } from '@/types/auth'
interface Props {
open: boolean
user: User | null
roles: Role[]
saving?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: [roleIds: number[]]
}>()
const selectedRoleIds = ref<Set<number>>(new Set())
watch(() => props.open, (isOpen) => {
if (!isOpen) return
selectedRoleIds.value = new Set(props.user?.roles?.map(r => r.id) ?? [])
})
function toggleRole(id: number, checked: boolean) {
if (checked) selectedRoleIds.value.add(id)
else selectedRoleIds.value.delete(id)
}
function handleSave() {
emit('saved', [...selectedRoleIds.value])
}
</script>
+11 -1
View File
@@ -15,5 +15,15 @@ export function usePermission() {
authStore.user?.role === 'coordinator' || !!authStore.user?.is_admin authStore.user?.role === 'coordinator' || !!authStore.user?.is_admin
) )
return { isAdmin, isCoordinatorOrAdmin } /**
* Resource/action check backed by the new multi-role permission system
* (e.g. hasPermission('shot', 'create')). Reads authStore.user.permissions,
* which is only ever populated on the current session's own user.
*/
function hasPermission(resource: string, action: string): boolean {
if (authStore.isAdmin) return true
return authStore.user?.permissions?.includes(`${resource}:${action}`) ?? false
}
return { isAdmin, isCoordinatorOrAdmin, hasPermission }
} }
+16
View File
@@ -79,6 +79,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/project/ProjectTasksView.vue'), component: () => import('@/views/project/ProjectTasksView.vue'),
meta: { tab: 'tasks', tabLabel: 'Tasks' } meta: { tab: 'tasks', tabLabel: 'Tasks' }
}, },
{
path: 'schedule',
name: 'ProjectSchedule',
component: () => import('@/views/project/ProjectScheduleView.vue'),
meta: { tab: 'schedule', tabLabel: 'Schedule' }
},
{ {
path: 'settings', path: 'settings',
name: 'ProjectSettings', name: 'ProjectSettings',
@@ -135,6 +141,16 @@ const routes: RouteRecordRaw[] = [
title: 'Recovery Management' title: 'Recovery Management'
} }
}, },
{
path: '/admin/roles',
name: 'RoleManagement',
component: () => import('@/views/admin/RoleManagementView.vue'),
meta: {
requiresAuth: true,
adminPermission: 'required',
title: 'Role Management'
}
},
// Developer routes // Developer routes
{ {
+14 -6
View File
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
label: string label: string
href?: string href?: string
isActive?: boolean isActive?: boolean
/** True for the crumb representing the current project tab (Overview/Shots/Assets/...), so the header can render a tab-switcher dropdown on it. */
isTabCrumb?: boolean
/** True for the crumb representing the current project name, so the header can render a project-switcher dropdown on it. */
isProjectCrumb?: boolean
} }
export class BreadcrumbService { export class BreadcrumbService {
@@ -33,7 +37,8 @@ export class BreadcrumbService {
// Add project breadcrumb // Add project breadcrumb
crumbs.push({ crumbs.push({
label: project ? project.name : `Project ${projectId}`, label: project ? project.name : `Project ${projectId}`,
href: `/projects/${projectId}` href: `/projects/${projectId}`,
isProjectCrumb: true
}) })
// Handle tab-based navigation // Handle tab-based navigation
@@ -45,7 +50,8 @@ export class BreadcrumbService {
if (tab === 'shots' && route.params.episodeId) { if (tab === 'shots' && route.params.episodeId) {
crumbs.push({ crumbs.push({
label: tabLabel, label: tabLabel,
href: `/projects/${projectId}/shots` href: `/projects/${projectId}/shots`,
isTabCrumb: true
}) })
// Add episode context // Add episode context
@@ -66,11 +72,12 @@ export class BreadcrumbService {
}) })
} }
} }
} else if (pathSegments[2] || tab !== 'overview') { } else {
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to) // Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
crumbs.push({ crumbs.push({
label: tabLabel, label: tabLabel,
isActive: true isActive: true,
isTabCrumb: true
}) })
} }
} }
@@ -85,7 +92,8 @@ export class BreadcrumbService {
if (crumbs.length > 1) { if (crumbs.length > 1) {
crumbs[crumbs.length - 1] = { crumbs[crumbs.length - 1] = {
label: 'Shots', label: 'Shots',
href: `/projects/${projectId}/shots` href: `/projects/${projectId}/shots`,
isTabCrumb: true
} }
} }
crumbs.push({ crumbs.push({
+87
View File
@@ -0,0 +1,87 @@
import { apiClient } from './api'
export type DepartmentType = 'shot' | 'asset'
export interface DepartmentInfo {
name: string
type: DepartmentType
task_types: string[]
}
export interface AllDepartmentsResponse {
departments: DepartmentInfo[]
standard_departments: DepartmentInfo[]
custom_departments: DepartmentInfo[]
}
export interface CustomDepartmentCreate {
department: string
department_type: DepartmentType
task_types?: string[]
}
export interface CustomDepartmentUpdate {
old_name: string
new_name: string
}
export interface DepartmentInUseError {
error: string
department: string
member_count: number
task_count: number
}
export interface DepartmentTaskTypeInUseError {
error: string
department: string
task_type: string
task_count: number
}
export const departmentService = {
async getAllDepartments(projectId: number): Promise<AllDepartmentsResponse> {
const response = await apiClient.get(`/projects/${projectId}/departments`)
return response.data
},
async addDepartment(projectId: number, data: CustomDepartmentCreate): Promise<AllDepartmentsResponse> {
const response = await apiClient.post(`/projects/${projectId}/departments`, data)
return response.data
},
async updateDepartment(projectId: number, department: string, data: CustomDepartmentUpdate): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}`, data)
return response.data
},
async deleteDepartment(projectId: number, department: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}`)
return response.data
},
async addDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const response = await apiClient.post(`/projects/${projectId}/departments/${encodedDepartment}/task-types`, { task_type: taskType })
return response.data
},
async renameDepartmentTaskType(projectId: number, department: string, oldTaskType: string, newTaskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const encodedTaskType = encodeURIComponent(oldTaskType)
const response = await apiClient.put(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`, {
old_name: oldTaskType,
new_name: newTaskType
})
return response.data
},
async removeDepartmentTaskType(projectId: number, department: string, taskType: string): Promise<AllDepartmentsResponse> {
const encodedDepartment = encodeURIComponent(department)
const encodedTaskType = encodeURIComponent(taskType)
const response = await apiClient.delete(`/projects/${projectId}/departments/${encodedDepartment}/task-types/${encodedTaskType}`)
return response.data
}
}
+30 -3
View File
@@ -26,7 +26,7 @@ export interface ProjectMember {
id: number id: number
user_id: number user_id: number
project_id: number project_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
joined_at: string joined_at: string
user_email: string user_email: string
user_first_name: string user_first_name: string
@@ -58,11 +58,11 @@ export interface ProjectUpdate {
export interface ProjectMemberCreate { export interface ProjectMemberCreate {
user_id: number user_id: number
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
} }
export interface ProjectMemberUpdate { export interface ProjectMemberUpdate {
department_role?: 'layout' | 'animation' | 'lighting' | 'composite' | 'modeling' | 'rigging' | 'surfacing' department_role?: string
} }
export interface DeliveryMovieSpec { export interface DeliveryMovieSpec {
@@ -95,6 +95,23 @@ export interface ProjectSettings {
enabled_shot_tasks?: string[] enabled_shot_tasks?: string[]
} }
export interface SubmissionTypeConfig {
allowed_extensions: string[]
naming_pattern_is_regex: boolean
naming_pattern?: string | null
check_naming: boolean
required: boolean
check_movie_spec: boolean
movie_resolution?: string | null
movie_format?: string | null
movie_codec?: string | null
movie_frame_rate?: number | null
}
export interface ProjectSubmissionConfig {
config_by_task_type: Record<string, SubmissionTypeConfig>
}
export const projectService = { export const projectService = {
async getUserProjects(): Promise<Project[]> { async getUserProjects(): Promise<Project[]> {
const response = await apiClient.get('/projects/') const response = await apiClient.get('/projects/')
@@ -182,6 +199,16 @@ export const projectService = {
return response.data return response.data
}, },
async getProjectSubmissionConfig(projectId: number): Promise<ProjectSubmissionConfig> {
const response = await apiClient.get(`/projects/${projectId}/submission-config`)
return response.data
},
async updateProjectSubmissionConfig(projectId: number, config: ProjectSubmissionConfig): Promise<ProjectSubmissionConfig> {
const response = await apiClient.put(`/projects/${projectId}/submission-config`, config)
return response.data
},
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> { async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
+63
View File
@@ -0,0 +1,63 @@
import { apiClient } from './api'
export interface Permission {
id: number
resource: string
action: string
description?: string | null
}
export interface Role {
id: number
name: string
description?: string | null
is_system: boolean
permissions: Permission[]
user_count: number
created_at: string
updated_at: string
}
export interface RoleCreate {
name: string
description?: string
permission_ids?: number[]
}
export interface RoleUpdate {
name?: string
description?: string
permission_ids?: number[]
}
export const roleService = {
async getRoles(): Promise<Role[]> {
const response = await apiClient.get('/roles/')
return response.data
},
async getPermissions(): Promise<Permission[]> {
const response = await apiClient.get('/roles/permissions')
return response.data
},
async createRole(data: RoleCreate): Promise<Role> {
const response = await apiClient.post('/roles/', data)
return response.data
},
async updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
const response = await apiClient.put(`/roles/${roleId}`, data)
return response.data
},
async deleteRole(roleId: number): Promise<{ message: string }> {
const response = await apiClient.delete(`/roles/${roleId}`)
return response.data
},
async updateUserRoles(userId: number, roleIds: number[]): Promise<{ message: string; user_id: number; role_ids: number[] }> {
const response = await apiClient.put(`/users/${userId}/roles`, { role_ids: roleIds })
return response.data
}
}
+31 -2
View File
@@ -11,6 +11,8 @@ export interface Task {
description?: string description?: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
department?: string
start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
project_name?: string project_name?: string
@@ -33,6 +35,8 @@ export interface TaskListItem {
name: string name: string
task_type: string task_type: string
status: TaskStatus status: TaskStatus
department?: string
start_date?: string
deadline?: string deadline?: string
project_id: number project_id: number
project_name: string project_name: string
@@ -55,9 +59,12 @@ export interface TaskStatusInfo {
assigned_user_id?: number assigned_user_id?: number
} }
export type NoteType = 'internal' | 'client'
export interface ProductionNote { export interface ProductionNote {
id: number id: number
content: string content: string
note_type: NoteType
parent_note_id?: number parent_note_id?: number
task_id: number task_id: number
user_id: number user_id: number
@@ -104,6 +111,11 @@ export interface Submission {
stream_url?: string stream_url?: string
} }
export interface SubmissionDateInfo {
task_id: number
submitted_at: string
}
export interface TaskFilters { export interface TaskFilters {
projectId?: number projectId?: number
shotId?: number shotId?: number
@@ -112,6 +124,7 @@ export interface TaskFilters {
status?: string status?: string
taskType?: string taskType?: string
departmentRole?: string departmentRole?: string
limit?: number
} }
export interface BulkStatusUpdateRequest { export interface BulkStatusUpdateRequest {
@@ -140,6 +153,7 @@ class TaskService {
if (filters?.status) params.append('status', filters.status) if (filters?.status) params.append('status', filters.status)
if (filters?.taskType) params.append('task_type', filters.taskType) if (filters?.taskType) params.append('task_type', filters.taskType)
if (filters?.departmentRole) params.append('department_role', filters.departmentRole) if (filters?.departmentRole) params.append('department_role', filters.departmentRole)
if (filters?.limit) params.append('limit', filters.limit.toString())
const response = await apiClient.get(`/tasks/?${params}`) const response = await apiClient.get(`/tasks/?${params}`)
return response.data return response.data
@@ -184,10 +198,11 @@ class TaskService {
return response.data return response.data
} }
async createTaskNote(taskId: number, content: string, parentNoteId?: number): Promise<ProductionNote> { async createTaskNote(taskId: number, content: string, parentNoteId?: number, noteType: NoteType = 'internal'): Promise<ProductionNote> {
const response = await apiClient.post(`/tasks/${taskId}/notes`, { const response = await apiClient.post(`/tasks/${taskId}/notes`, {
content, content,
parent_note_id: parentNoteId parent_note_id: parentNoteId,
note_type: noteType
}) })
return response.data return response.data
} }
@@ -241,6 +256,11 @@ class TaskService {
return response.data return response.data
} }
async getSubmissionDates(projectId: number): Promise<SubmissionDateInfo[]> {
const response = await apiClient.get(`/tasks/submission-dates?project_id=${projectId}`)
return response.data
}
async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> { async submitWork(taskId: number, file: File, notes?: string): Promise<Submission> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
@@ -256,6 +276,15 @@ class TaskService {
return response.data return response.data
} }
async updateSubmission(taskId: number, submissionId: number, notes: string): Promise<Submission> {
const response = await apiClient.put(`/tasks/${taskId}/submissions/${submissionId}`, { notes })
return response.data
}
async deleteSubmission(taskId: number, submissionId: number): Promise<void> {
await apiClient.delete(`/tasks/${taskId}/submissions/${submissionId}`)
}
async createAssetTask(assetId: number, taskType: string): Promise<TaskStatusInfo> { async createAssetTask(assetId: number, taskType: string): Promise<TaskStatusInfo> {
const response = await apiClient.post(`/assets/${assetId}/tasks?task_type=${taskType}`) const response = await apiClient.post(`/assets/${assetId}/tasks?task_type=${taskType}`)
return response.data return response.data
+159
View File
@@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { departmentService, type AllDepartmentsResponse, type DepartmentInfo, type DepartmentType } from '@/services/department'
interface ProjectDepartments {
projectId: number
data: AllDepartmentsResponse
lastFetched: number
}
export const useDepartmentsStore = defineStore('departments', () => {
// Cache departments by project ID
const projectDepartments = ref<Map<number, ProjectDepartments>>(new Map())
const loading = ref<Set<number>>(new Set())
const error = ref<string | null>(null)
// In-flight request de-dup: concurrent callers for the same project share one promise
const inFlightRequests = new Map<number, Promise<AllDepartmentsResponse>>()
// Cache duration: 5 minutes
const CACHE_DURATION = 5 * 60 * 1000
// Get cached departments for a project
const getProjectDepartments = computed(() => {
return (projectId: number): AllDepartmentsResponse | null => {
const cached = projectDepartments.value.get(projectId)
if (!cached) return null
// Check if cache is still valid
const now = Date.now()
if (now - cached.lastFetched > CACHE_DURATION) {
// Cache expired, remove it
projectDepartments.value.delete(projectId)
return null
}
return cached.data
}
})
// Check if departments are currently being loaded for a project
const isLoading = computed(() => {
return (projectId: number): boolean => {
return loading.value.has(projectId)
}
})
// Get all department names (standard + custom) for a project
const getAllDepartmentOptions = computed(() => {
return (projectId: number): string[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
return departments.departments.map(d => d.name)
}
})
// Get all departments of a given type (shot or asset) for a project
const getDepartmentsByType = computed(() => {
return (projectId: number, type: DepartmentType): DepartmentInfo[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
return departments.departments.filter(d => d.type === type)
}
})
// Get the task types owned by a specific department for a project
const getDepartmentTaskTypes = computed(() => {
return (projectId: number, departmentName: string): string[] => {
const departments = getProjectDepartments.value(projectId)
if (!departments) return []
const department = departments.departments.find(d => d.name === departmentName)
return department?.task_types || []
}
})
// Fetch departments for a project
async function fetchProjectDepartments(projectId: number, force = false): Promise<AllDepartmentsResponse> {
// Return cached data if available and not forced
if (!force) {
const cached = getProjectDepartments.value(projectId)
if (cached) {
return cached
}
}
// Share the in-flight request with any concurrent callers instead of re-fetching
const existing = inFlightRequests.get(projectId)
if (existing) {
return existing
}
loading.value.add(projectId)
error.value = null
const request = (async () => {
try {
const data = await departmentService.getAllDepartments(projectId)
// Cache the result
projectDepartments.value.set(projectId, {
projectId,
data,
lastFetched: Date.now()
})
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch departments'
console.error('Error fetching departments:', err)
throw err
} finally {
loading.value.delete(projectId)
inFlightRequests.delete(projectId)
}
})()
inFlightRequests.set(projectId, request)
return request
}
// Invalidate cache for a project (useful after creating/updating/deleting departments)
function invalidateProject(projectId: number) {
projectDepartments.value.delete(projectId)
}
// Clear all cached data
function clearCache() {
projectDepartments.value.clear()
loading.value.clear()
error.value = null
}
// Update cached departments after a change (to avoid refetch)
function updateProjectDepartments(projectId: number, data: AllDepartmentsResponse) {
projectDepartments.value.set(projectId, {
projectId,
data,
lastFetched: Date.now()
})
}
return {
// State
error,
// Computed
getProjectDepartments,
isLoading,
getAllDepartmentOptions,
getDepartmentsByType,
getDepartmentTaskTypes,
// Actions
fetchProjectDepartments,
invalidateProject,
clearCache,
updateProjectDepartments
}
})
+99
View File
@@ -0,0 +1,99 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { roleService, type Role, type Permission, type RoleCreate, type RoleUpdate } from '@/services/role'
// Cache duration: 5 minutes
const CACHE_DURATION = 5 * 60 * 1000
export const useRolesStore = defineStore('roles', () => {
const roles = ref<Role[] | null>(null)
const permissions = ref<Permission[] | null>(null)
const rolesLastFetched = ref<number>(0)
const permissionsLastFetched = ref<number>(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
let rolesInFlight: Promise<Role[]> | null = null
let permissionsInFlight: Promise<Permission[]> | null = null
async function fetchRoles(force = false): Promise<Role[]> {
const now = Date.now()
if (!force && roles.value && now - rolesLastFetched.value < CACHE_DURATION) {
return roles.value
}
if (rolesInFlight) return rolesInFlight
isLoading.value = true
error.value = null
rolesInFlight = (async () => {
try {
const data = await roleService.getRoles()
roles.value = data
rolesLastFetched.value = Date.now()
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch roles'
throw err
} finally {
isLoading.value = false
rolesInFlight = null
}
})()
return rolesInFlight
}
async function fetchPermissions(force = false): Promise<Permission[]> {
const now = Date.now()
if (!force && permissions.value && now - permissionsLastFetched.value < CACHE_DURATION) {
return permissions.value
}
if (permissionsInFlight) return permissionsInFlight
permissionsInFlight = (async () => {
try {
const data = await roleService.getPermissions()
permissions.value = data
permissionsLastFetched.value = Date.now()
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch permissions'
throw err
} finally {
permissionsInFlight = null
}
})()
return permissionsInFlight
}
async function createRole(data: RoleCreate): Promise<Role> {
const role = await roleService.createRole(data)
if (roles.value) roles.value = [...roles.value, role]
return role
}
async function updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
const role = await roleService.updateRole(roleId, data)
if (roles.value) roles.value = roles.value.map(r => r.id === roleId ? role : r)
return role
}
async function deleteRole(roleId: number): Promise<void> {
await roleService.deleteRole(roleId)
if (roles.value) roles.value = roles.value.filter(r => r.id !== roleId)
}
return {
roles,
permissions,
isLoading,
error,
fetchRoles,
fetchPermissions,
createRole,
updateRole,
deleteRole
}
})
+9
View File
@@ -1,3 +1,9 @@
export interface RoleSummary {
id: number
name: string
is_system: boolean
}
export interface User { export interface User {
id: number id: number
email: string email: string
@@ -9,6 +15,9 @@ export interface User {
avatar_url?: string | null avatar_url?: string | null
created_at: string created_at: string
updated_at: string updated_at: string
roles?: RoleSummary[]
/** Only populated on the current session's own user (from GET /users/me). */
permissions?: string[]
} }
export interface LoginCredentials { export interface LoginCredentials {
-10
View File
@@ -38,15 +38,6 @@
<!-- </div> --> <!-- </div> -->
</div> </div>
<!-- Project Tabs -->
<div class="px-0 sm:px-0 pb-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" v-if="project">
<ProjectTabs
:project-id="project.id"
:shot-count="project.shot_count"
:asset-count="project.asset_count"
/>
</div>
<!-- Tab Content Area --> <!-- Tab Content Area -->
<div class="flex-1 overflow-auto bg-muted/30"> <div class="flex-1 overflow-auto bg-muted/30">
<router-view :key="route.fullPath" /> <router-view :key="route.fullPath" />
@@ -87,7 +78,6 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import ProjectTabs from '@/components/project/ProjectTabs.vue'
import type { Project } from '@/stores/projects' import type { Project } from '@/stores/projects'
const router = useRouter() const router = useRouter()
+36 -2
View File
@@ -33,7 +33,7 @@
<!-- Tabbed Interface --> <!-- Tabbed Interface -->
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full"> <Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
<TabsList class="grid w-full grid-cols-6"> <TabsList class="grid w-full grid-cols-8">
<TabsTrigger value="general"> <TabsTrigger value="general">
<Settings class="h-4 w-4 mr-2" /> <Settings class="h-4 w-4 mr-2" />
General General
@@ -46,6 +46,10 @@
<Users class="h-4 w-4 mr-2" /> <Users class="h-4 w-4 mr-2" />
Team Team
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="departments">
<Building2 class="h-4 w-4 mr-2" />
Departments
</TabsTrigger>
<TabsTrigger value="technical"> <TabsTrigger value="technical">
<Cog class="h-4 w-4 mr-2" /> <Cog class="h-4 w-4 mr-2" />
Technical Technical
@@ -54,6 +58,10 @@
<ListChecks class="h-4 w-4 mr-2" /> <ListChecks class="h-4 w-4 mr-2" />
Tasks Tasks
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="submissions">
<UploadCloud class="h-4 w-4 mr-2" />
Submissions
</TabsTrigger>
<TabsTrigger value="storage"> <TabsTrigger value="storage">
<FolderOpen class="h-4 w-4 mr-2" /> <FolderOpen class="h-4 w-4 mr-2" />
Storage Storage
@@ -105,6 +113,16 @@
</div> </div>
</TabsContent> </TabsContent>
<!-- Department Management Tab -->
<TabsContent value="departments" class="mt-6">
<div class="bg-card rounded-lg border p-6">
<DepartmentManager
:project-id="projectId"
@updated="handleDepartmentsUpdated"
/>
</div>
</TabsContent>
<!-- Technical Specifications Tab --> <!-- Technical Specifications Tab -->
<TabsContent value="technical" class="mt-6"> <TabsContent value="technical" class="mt-6">
<div class="bg-card rounded-lg border p-6"> <div class="bg-card rounded-lg border p-6">
@@ -156,6 +174,13 @@
</div> </div>
</TabsContent> </TabsContent>
<!-- Submission Configuration Tab -->
<TabsContent value="submissions" class="mt-6">
<div class="bg-card rounded-lg border p-6">
<SubmissionConfigManager :project-id="projectId" />
</div>
</TabsContent>
<!-- Upload Location Tab --> <!-- Upload Location Tab -->
<TabsContent value="storage" class="mt-6"> <TabsContent value="storage" class="mt-6">
<div class="bg-card rounded-lg border p-6"> <div class="bg-card rounded-lg border p-6">
@@ -177,7 +202,7 @@ import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film, AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen ListChecks, FolderOpen, UploadCloud, Building2
} from "lucide-vue-next"; } from "lucide-vue-next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -193,8 +218,10 @@ import ProjectThumbnailUpload from "@/components/project/ProjectThumbnailUpload.
import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue"; import EpisodeManagementSection from "@/components/settings/EpisodeManagementSection.vue";
import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue"; import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManager.vue";
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue"; import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
import DepartmentManager from "@/components/settings/DepartmentManager.vue";
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue"; import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue"; import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
import { projectService } from "@/services/project"; import { projectService } from "@/services/project";
import type { Project } from "@/stores/projects"; import type { Project } from "@/stores/projects";
@@ -378,6 +405,13 @@ const handleTaskStatusesUpdated = async () => {
}); });
}; };
const handleDepartmentsUpdated = () => {
toast({
title: 'Departments updated',
description: 'Department changes have been saved successfully.'
});
};
const handleTaskTypesUpdated = async () => { const handleTaskTypesUpdated = async () => {
// Refresh task types in the task templates editor // Refresh task types in the task templates editor
if (taskTemplatesEditorRef.value) { if (taskTemplatesEditorRef.value) {
+10
View File
@@ -125,11 +125,13 @@ import { TaskStatus } from '@/services/asset'
import { episodeService, type Episode } from '@/services/episode' import { episodeService, type Episode } from '@/services/episode'
import { projectService, type Project, type ProjectMember } from '@/services/project' import { projectService, type Project, type ProjectMember } from '@/services/project'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel' import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast() const { toast } = useToast()
const authStore = useAuthStore() const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
// Detail panel composable // Detail panel composable
const { const {
@@ -307,6 +309,14 @@ async function fetchProjectScopedFilters() {
episodes.value = [] episodes.value = []
projectMembers.value = [] projectMembers.value = []
} }
// Ensures the "Change Status" table-header dropdown has real per-project status
// options ready as soon as a project is picked, not just once a row happens to render.
try {
await taskStatusesStore.fetchProjectStatuses(projectId)
} catch (error) {
console.error('Failed to fetch task statuses:', error)
}
} }
const handleRowClick = () => { const handleRowClick = () => {
+42
View File
@@ -56,6 +56,7 @@
@approve-user="handleApproveUser" @approve-user="handleApproveUser"
@edit-user="handleEditUser" @edit-user="handleEditUser"
@reset-password="handleResetPassword" @reset-password="handleResetPassword"
@manage-roles="handleManageRoles"
@delete-user="handleDeleteUser" @delete-user="handleDeleteUser"
/> />
</div> </div>
@@ -112,6 +113,16 @@
:is-deleting="isDeletingUser" :is-deleting="isDeletingUser"
/> />
<!-- User Roles (custom multi-role) Dialog -->
<UserRolesDialog
:open="showRolesDialog"
@update:open="showRolesDialog = $event"
:user="selectedUser"
:roles="rolesStore.roles ?? []"
:saving="isSavingRoles"
@saved="handleUserRolesSubmit"
/>
<!-- Success Toast --> <!-- Success Toast -->
<div v-if="successMessage" class="fixed bottom-4 right-4 z-50"> <div v-if="successMessage" class="fixed bottom-4 right-4 z-50">
<Alert class="w-80 bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800"> <Alert class="w-80 bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800">
@@ -131,12 +142,15 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Users, UserPlus, RefreshCw, AlertCircle, Loader2, CheckCircle } from 'lucide-vue-next' import { Users, UserPlus, RefreshCw, AlertCircle, Loader2, CheckCircle } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRolesStore } from '@/stores/roles'
import { roleService } from '@/services/role'
import PendingUsersDashboard from '@/components/user/PendingUsersDashboard.vue' import PendingUsersDashboard from '@/components/user/PendingUsersDashboard.vue'
import UserManagementTable from '@/components/user/UserManagementTable.vue' import UserManagementTable from '@/components/user/UserManagementTable.vue'
import UserCreateDialog from '@/components/user/UserCreateDialog.vue' import UserCreateDialog from '@/components/user/UserCreateDialog.vue'
import UserEditDialog from '@/components/user/UserEditDialog.vue' import UserEditDialog from '@/components/user/UserEditDialog.vue'
import PasswordResetDialog from '@/components/user/PasswordResetDialog.vue' import PasswordResetDialog from '@/components/user/PasswordResetDialog.vue'
import UserDeleteConfirmDialog from '@/components/user/UserDeleteConfirmDialog.vue' import UserDeleteConfirmDialog from '@/components/user/UserDeleteConfirmDialog.vue'
import UserRolesDialog from '@/components/user/UserRolesDialog.vue'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
import type { UserCreateData } from '@/components/user/UserCreateDialog.vue' import type { UserCreateData } from '@/components/user/UserCreateDialog.vue'
import type { UserEditData } from '@/components/user/UserEditDialog.vue' import type { UserEditData } from '@/components/user/UserEditDialog.vue'
@@ -144,6 +158,7 @@ import type { UserEditData } from '@/components/user/UserEditDialog.vue'
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const rolesStore = useRolesStore()
// Local state // Local state
const processingUserId = ref<number | null>(null) const processingUserId = ref<number | null>(null)
@@ -157,12 +172,14 @@ const showCreateDialog = ref(false)
const showEditDialog = ref(false) const showEditDialog = ref(false)
const showPasswordResetDialog = ref(false) const showPasswordResetDialog = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showRolesDialog = ref(false)
// Loading states // Loading states
const isCreatingUser = ref(false) const isCreatingUser = ref(false)
const isEditingUser = ref(false) const isEditingUser = ref(false)
const isResettingPassword = ref(false) const isResettingPassword = ref(false)
const isDeletingUser = ref(false) const isDeletingUser = ref(false)
const isSavingRoles = ref(false)
// Computed // Computed
const users = computed(() => userStore.users) const users = computed(() => userStore.users)
@@ -287,6 +304,31 @@ const handleResetPassword = (user: User) => {
showPasswordResetDialog.value = true showPasswordResetDialog.value = true
} }
const handleManageRoles = async (user: User) => {
selectedUser.value = user
showRolesDialog.value = true
try {
await rolesStore.fetchRoles()
} catch (err) {
console.error('Failed to load roles:', err)
}
}
const handleUserRolesSubmit = async (roleIds: number[]) => {
if (!selectedUser.value) return
try {
isSavingRoles.value = true
await roleService.updateUserRoles(selectedUser.value.id, roleIds)
showRolesDialog.value = false
showSuccessMessage('User roles updated successfully')
await refreshData()
} catch (err: any) {
showErrorMessage(err.response?.data?.detail || 'Failed to update user roles')
} finally {
isSavingRoles.value = false
}
}
const handlePasswordResetSubmit = async (userId: number, password: string) => { const handlePasswordResetSubmit = async (userId: number, password: string) => {
try { try {
isResettingPassword.value = true isResettingPassword.value = true
@@ -0,0 +1,171 @@
<template>
<div class="container mx-auto py-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Role Management</h1>
<p class="text-muted-foreground">
Create custom roles and edit create/edit/delete permissions for assets, shots, and tasks
</p>
</div>
<Button @click="openCreateDialog">
<Plus class="w-4 h-4 mr-2" />
Create Role
</Button>
</div>
<Card>
<CardContent class="p-0">
<div v-if="isLoading" class="p-6 text-center text-muted-foreground">Loading roles...</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="text-left font-medium p-3">Name</th>
<th class="text-left font-medium p-3">Description</th>
<th class="text-left font-medium p-3">Permissions</th>
<th class="text-left font-medium p-3">Users</th>
<th class="text-right font-medium p-3">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="role in roles" :key="role.id" class="border-b last:border-b-0">
<td class="p-3 font-medium">
{{ role.name }}
<Badge :variant="role.is_system ? 'secondary' : 'outline'" class="ml-2">
{{ role.is_system ? 'System' : 'Custom' }}
</Badge>
</td>
<td class="p-3 text-muted-foreground">{{ role.description || '—' }}</td>
<td class="p-3">{{ role.permissions.length }}</td>
<td class="p-3">{{ role.user_count }}</td>
<td class="p-3 text-right space-x-2">
<Button variant="ghost" size="sm" @click="openEditDialog(role)">
<Pencil class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
:disabled="role.is_system || role.user_count > 0"
:title="role.is_system ? 'System roles cannot be deleted' : role.user_count > 0 ? 'Reassign users before deleting' : 'Delete role'"
@click="confirmDelete(role)"
>
<Trash2 class="w-4 h-4" />
</Button>
</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
<RoleFormDialog
v-model:open="showFormDialog"
:role="editingRole"
:permissions="permissions"
:saving="isSaving"
@saved="handleSave"
/>
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete role "{{ roleToDelete?.name }}"?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Plus, Pencil, Trash2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction
} from '@/components/ui/alert-dialog'
import { useToast } from '@/components/ui/toast/use-toast'
import { useRolesStore } from '@/stores/roles'
import RoleFormDialog from '@/components/role/RoleFormDialog.vue'
import type { Role } from '@/services/role'
const { toast } = useToast()
const rolesStore = useRolesStore()
const isLoading = ref(true)
const isSaving = ref(false)
const showFormDialog = ref(false)
const editingRole = ref<Role | null>(null)
const showDeleteDialog = ref(false)
const roleToDelete = ref<Role | null>(null)
const roles = computed(() => rolesStore.roles ?? [])
const permissions = computed(() => rolesStore.permissions ?? [])
onMounted(async () => {
try {
await Promise.all([rolesStore.fetchRoles(), rolesStore.fetchPermissions()])
} catch (err: any) {
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to load roles', variant: 'destructive' })
} finally {
isLoading.value = false
}
})
function openCreateDialog() {
editingRole.value = null
showFormDialog.value = true
}
function openEditDialog(role: Role) {
editingRole.value = role
showFormDialog.value = true
}
async function handleSave(data: { name?: string; description?: string; permission_ids: number[] }) {
isSaving.value = true
try {
if (editingRole.value) {
await rolesStore.updateRole(editingRole.value.id, data)
toast({ title: 'Role updated', description: `"${editingRole.value.name}" was updated.` })
} else {
const role = await rolesStore.createRole({ name: data.name!, description: data.description, permission_ids: data.permission_ids })
toast({ title: 'Role created', description: `"${role.name}" was created.` })
}
showFormDialog.value = false
} catch (err: any) {
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to save role', variant: 'destructive' })
} finally {
isSaving.value = false
}
}
function confirmDelete(role: Role) {
roleToDelete.value = role
showDeleteDialog.value = true
}
async function handleDelete() {
// Captured locally: AlertDialogAction closes the dialog (and fires @update:open)
// as part of the same click, so showDeleteDialog can't be trusted to still
// reflect "open" by the time this runs - roleToDelete is never reset by that
// close, only here, so it's safe to read.
const role = roleToDelete.value
if (!role) return
try {
await rolesStore.deleteRole(role.id)
toast({ title: 'Role deleted', description: `"${role.name}" was deleted.` })
} catch (err: any) {
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to delete role', variant: 'destructive' })
} finally {
showDeleteDialog.value = false
roleToDelete.value = null
}
}
</script>
@@ -0,0 +1,50 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="px-4 sm:px-6 py-4 sm:py-6 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Schedule</h2>
<p class="text-sm text-muted-foreground mt-1">
Production schedule for project planning
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
<ScheduleGantt :project-id="projectId" />
</div>
<div v-else class="p-4 sm:p-6">
<!-- No project selected -->
<Card>
<CardContent class="p-12 text-center">
<GanttChartSquare class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-semibold mb-2">No Project Selected</h3>
<p class="text-muted-foreground mb-4">
Please select a project to view its schedule.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { GanttChartSquare } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import ScheduleGantt from '@/components/schedule/ScheduleGantt.vue'
const route = useRoute()
const projectId = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? parseInt(id) : null
})
</script>