Compare commits

..

28 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
indigo f013e1cf25 Fix wrong selected-task count in the task bulk-actions context menu
handleContextMenu (and the shift-click range-selection path) resolved the
target row via props.tasks[index], but index is the row's position in the
sorted/rendered row model (table.getRowModel().rows), not the raw prop
array — the two orders rarely match once the default created_at sort is
applied. This caused right-click (and shift-click) to select the wrong row,
corrupting the count shown in TaskBulkActionsMenu. Both call sites now
resolve the task from the actual row data instead of an index lookup into
the unsorted array, matching the pattern ShotsDataTable.vue already used
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 13:19:42 +08:00
indigo e628c99498 Sticky table headers, layout parity, and reuse My Tasks on the shared table/toolbar
Shot/Asset/Task data tables now keep their header row pinned while scrolling
(both the locked two-pane and single-table modes), matching a common data-
table expectation that was missing everywhere.

Brings Asset and Task browsers to full structural parity with Shot's bounded-
height layout (fixed toolbar, internally-scrolling table) instead of their
previous page-scroll model with a sticky-positioned toolbar. AssetsDataTable
gained the synced frozen/movable-pane scrolling it was missing entirely;
TasksDataTable and both browsers/parent views got the same container model.

The global My Tasks page (/tasks) now reuses TaskTableToolbar and
TasksDataTable instead of its own ad hoc filter selects and TaskList,
including the detail panel, bulk status/assignment context menu, and sticky
header for free. Since it spans multiple projects, TaskTableToolbar gained
an optional Project filter (only rendered when a project list is passed in,
so the project-scoped Tasks page is unaffected); episode/assignee filters
populate once a specific project is chosen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:15:04 +08:00
indigo 442abc3586 Fix unresolved Activity component warning in ActivityFeed.vue
The lucide icon was imported as "Activity as ActivityIcon" but the empty-
state template still referenced <Activity>, which was never registered
under that name — triggering a Vue "failed to resolve component" warning
whenever a project's activity feed was empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 04:18:17 +08:00
indigo 9527f06b3f Fix activities endpoints to match established project-access convention
get_project_activities, get_task_activities, and get_recent_activities were
gating on "project member OR admin", incorrectly blocking coordinators,
directors, and developers from projects they weren't explicitly added to as
members. Every other project-scoped router (shots.py, assets.py) only
restricts the artist role this way — everyone else has access regardless of
membership. Brought activities.py in line with that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 04:18:07 +08:00
indigo 4f9deeb57a Phase 4: Architecture & tooling investment
Fixes a real N+1 API-call bug (per-cell project-member fetches in shot/asset
task-status cells, now a shared cached store), replaces a polling-based
in-flight request de-dup with promise memoization, adds markRaw() around
icon components in reactive state, and fixes a deep watcher that re-scanned
the whole projects array just to trigger thumbnail loading.

Introduces useAsyncAction and usePermission composables to cut duplicated
store boilerplate and duplicated role/admin checks, applied only where the
existing code was a clean match rather than forced onto everything. Extracts
assets.ts's shared per-asset optimistic-update/rollback helper without
merging the single and bulk API paths, which hit genuinely different
endpoints. Adds toast-on-error for previously-silent secondary loads and
fixes a dead try/catch in the router's auth-init guard along the way.

Also fixes a thumbnail-loading regression introduced earlier in this same
pass: the new ID-keyed watcher never fired on a repeat visit to the Projects
page when the store already held the same project list, so thumbnails
(local component state, reset per mount) silently stopped loading after the
first visit. Replaced the watcher with a direct call after each fetch.

Table virtualization, splitting the two largest view files, broad store
caching, and moving domain types into types/ were scoped out as separate,
higher-risk follow-ups (documented in frontend_tasks.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 04:03:58 +08:00
indigo 841e786fdd Fix task assignee resolution and align task status styling in ShotDetailPanel
The Tasks list embedded assigned_user_name: undefined with a "will be
resolved if needed" comment that was never followed up on, so every task
showed "Unassigned" regardless of actual assignment. Resolve it from the
project members list, matching how the shot table's own assignment control
does it, and show an avatar next to the name. Also swap the hardcoded
status Badge/variant switch for the same TaskStatusBadge + task-statuses
store the shot table uses, so status colors and labels (including custom
per-project statuses) always match between the table and the panel.
2026-07-18 02:17:06 +08:00
indigo 26807984ee Phase 3: Unify controls across shot/asset/task toolbars and detail panels
Extracts duplication that built up as shot/asset/task features reached
parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled
checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel
toggle, clear-filters, segmented view/context toggle) replace copy-pasted
markup in the three table toolbars, icon-only buttons standardize on the
icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status,
and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three
detail panels (adding a previously-missing error state to the task panel).
2026-07-18 02:15:55 +08:00
130 changed files with 9672 additions and 2795 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
from database import engine, Base
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency, roles
# Import models to ensure they are registered with SQLAlchemy
import models
@@ -109,6 +109,7 @@ app.include_router(notifications.router, tags=["notifications"])
app.include_router(activities.router, tags=["activities"])
app.include_router(admin.router, prefix="/admin", tags=["admin"])
app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"])
app.include_router(roles.router, prefix="/roles", tags=["roles"])
@app.get("/")
+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
from .user import User, UserRole, DepartmentRole
from .user import User, UserRole
from .project import Project, ProjectMember, ProjectStatus
from .episode import Episode, EpisodeStatus
from .asset import Asset, AssetCategory, AssetStatus
from .shot import Shot, ShotStatus
from .task import (
Task, Submission, Review, ProductionNote, TaskAttachment,
TaskType, TaskStatus, ReviewDecision, AttachmentType
TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
)
from .api_key import APIKey, APIKeyScope
from .api_key_usage import APIKeyUsage
from .global_settings import GlobalSettings
from .notification import Notification, UserNotificationPreference, NotificationType
from .activity import Activity, ActivityType
from .role import Role, Permission, role_permissions, user_roles
__all__ = [
# User models
"User", "UserRole", "DepartmentRole",
"User", "UserRole",
# Project models
"Project", "ProjectMember", "ProjectStatus",
# Episode models
@@ -27,7 +28,7 @@ __all__ = [
"Shot", "ShotStatus",
# Task models
"Task", "Submission", "Review", "ProductionNote", "TaskAttachment",
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType",
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "NoteType",
# API Key models
"APIKey", "APIKeyScope", "APIKeyUsage",
# Global Settings models
@@ -35,5 +36,7 @@ __all__ = [
# Notification models
"Notification", "UserNotificationPreference", "NotificationType",
# Activity models
"Activity", "ActivityType"
"Activity", "ActivityType",
# Role models
"Role", "Permission", "role_permissions", "user_roles"
]
+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.sql import func
from database import Base
from .user import DepartmentRole
import enum
@@ -54,6 +53,12 @@ class Project(Base):
# Custom task statuses
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
# Custom departments
custom_departments = Column(JSON, nullable=True) # Custom departments for project (in addition to standard ones)
# Submission configuration per task type
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
# Project thumbnail
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
@@ -77,7 +82,7 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
department_role = Column(Enum(DepartmentRole), nullable=True)
department_role = Column(String, nullable=True) # Free-form: standard department or a project's custom department
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
+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"
class NoteType(str, enum.Enum):
INTERNAL = "internal"
CLIENT = "client"
class Task(Base):
__tablename__ = "tasks"
@@ -51,6 +56,8 @@ class Task(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -190,6 +197,7 @@ class ProductionNote(Base):
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
note_type = Column(Enum(NoteType), nullable=False, default=NoteType.INTERNAL)
parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+1 -10
View File
@@ -12,16 +12,6 @@ class UserRole(str, enum.Enum):
DEVELOPER = "developer"
class DepartmentRole(str, enum.Enum):
LAYOUT = "layout"
ANIMATION = "animation"
LIGHTING = "lighting"
COMPOSITE = "composite"
MODELING = "modeling"
RIGGING = "rigging"
SURFACING = "surfacing"
class User(Base):
__tablename__ = "users"
@@ -47,6 +37,7 @@ class User(Base):
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
notification_preferences = relationship("UserNotificationPreference", back_populates="user", uselist=False, cascade="all, delete-orphan")
roles = relationship("Role", secondary="user_roles", back_populates="users")
def __repr__(self):
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
+22 -18
View File
@@ -5,7 +5,7 @@ from typing import List, Optional
from datetime import datetime, timedelta
from database import get_db
from models.user import User
from models.user import User, UserRole
from models.activity import Activity, ActivityType
from models.project import ProjectMember
from schemas.activity import ActivityResponse
@@ -26,13 +26,15 @@ def get_project_activities(
current_user: User = Depends(get_current_user)
):
"""Get activity feed for a specific project (excludes activities for deleted records)."""
# Verify user has access to the project
# Only artists are restricted to their explicit project memberships; coordinators,
# directors, developers, and admins have access to all projects (matches shots.py/assets.py).
if current_user.role == UserRole.ARTIST:
member = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
).first()
if not member and not current_user.is_admin:
if not member:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Access denied to this project")
@@ -66,13 +68,15 @@ def get_task_activities(
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Task not found")
# Check if user is a member of the project
# Only artists are restricted to their explicit project memberships; coordinators,
# directors, developers, and admins have access to all tasks (matches shots.py/assets.py).
if current_user.role == UserRole.ARTIST:
member = db.query(ProjectMember).filter(
ProjectMember.project_id == task.project_id,
ProjectMember.user_id == current_user.id
).first()
if not member and not current_user.is_admin:
if not member:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Access denied to this task")
@@ -122,19 +126,27 @@ def get_recent_activities(
current_user: User = Depends(get_current_user)
):
"""Get recent activities from all projects the user has access to (excludes activities for deleted records)."""
# Get all projects the user is a member of
# Only artists are restricted to their explicit project memberships; coordinators,
# directors, developers, and admins see recent activity across all projects
# (matches shots.py/assets.py).
if current_user.role != UserRole.ARTIST:
activities = ActivityService.get_activities_excluding_deleted(
db=db,
skip=skip,
limit=limit
)
return activities
# Get all projects the artist is a member of
project_ids = db.query(ProjectMember.project_id).filter(
ProjectMember.user_id == current_user.id
).all()
project_ids = [pid[0] for pid in project_ids]
if not project_ids and not current_user.is_admin:
if not project_ids:
return []
# For non-admin users, filter by their project access
if not current_user.is_admin:
# Get activities from user's projects, excluding deleted records
all_activities = []
for project_id in project_ids:
activities = ActivityService.get_activities_excluding_deleted(
@@ -148,14 +160,6 @@ def get_recent_activities(
# Sort by created_at and apply pagination
all_activities.sort(key=lambda x: x.created_at, reverse=True)
return all_activities[skip:skip + limit]
else:
# Admin gets all activities excluding deleted records
activities = ActivityService.get_activities_excluding_deleted(
db=db,
skip=skip,
limit=limit
)
return activities
# Admin-only endpoints that include activities for deleted records
+16 -28
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Dict
from typing import List, Dict, Optional
from database import get_db
from models.asset import Asset, AssetCategory
@@ -9,7 +9,8 @@ from models.task import Task, TaskType, TaskStatus
from models.user import User, UserRole
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
from schemas.task import TaskCreate
from utils.auth import get_current_user_from_token
from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.asset_soft_deletion import AssetSoftDeletionService
router = APIRouter()
@@ -24,22 +25,6 @@ def get_current_user_with_db(
return _get_user_from_db(db, token_data["user_id"])
def require_coordinator_or_admin(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
):
"""Require coordinator or admin role."""
from utils.auth import _get_user_from_db
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
"""Get sort order for task status, including custom statuses."""
# Default system status order
@@ -135,7 +120,7 @@ def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_ASSET_TASK_TYPES + custom_types
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]:
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session, project: Optional[Project] = None) -> List[Task]:
"""Create default tasks for an asset."""
created_tasks = []
@@ -150,7 +135,8 @@ def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Sess
task_type=task_type,
name=task_name,
description=f"Default {task_type} task for {asset.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type) if project else None
)
db.add(db_task)
@@ -374,11 +360,11 @@ async def create_asset(
asset: AssetCreate,
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'create'))
):
"""Create a new asset in a project with optional default tasks"""
# Check project access
check_project_access(project_id, current_user, db)
project = check_project_access(project_id, current_user, db)
# Check if asset name already exists in project (exclude soft deleted)
existing_asset = db.query(Asset).filter(
@@ -424,7 +410,7 @@ async def create_asset(
task_types = get_default_asset_task_types(asset.category)
# Create the tasks
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db)
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db, project)
task_count = len(created_tasks)
db.commit()
@@ -536,7 +522,7 @@ async def create_asset_task(
asset_id: int,
task_type: str, # Changed from TaskType enum to str
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task for an asset"""
# Exclude soft deleted assets
@@ -569,13 +555,15 @@ async def create_asset_task(
# Create the task
task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task(
project_id=asset.project_id,
asset_id=asset.id,
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {asset.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
@@ -595,7 +583,7 @@ async def update_asset(
asset_id: int,
asset_update: AssetUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'edit'))
):
"""Update an asset"""
# Exclude soft deleted assets
@@ -651,7 +639,7 @@ async def update_asset(
async def get_asset_deletion_info(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'delete'))
):
"""Get information about what will be deleted when deleting an asset"""
# Exclude soft deleted assets
@@ -701,7 +689,7 @@ async def get_asset_deletion_info(
async def delete_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'delete'))
):
"""Soft delete an asset and all its associated data"""
# Exclude soft deleted assets
+4
View File
@@ -55,6 +55,10 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)):
db.commit()
db.refresh(new_user)
from utils.auth import link_system_role
link_system_role(new_user, db)
db.commit()
return {
"message": "User registered successfully. Awaiting admin approval.",
"user_id": new_user.id
+597
View File
@@ -16,6 +16,7 @@ from schemas.project import (
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
)
from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
from utils.auth import get_current_user, require_role, get_current_user_from_token
router = APIRouter()
@@ -1077,6 +1078,602 @@ async def delete_custom_task_type(
return _build_all_task_types_response(db_project)
# Department Management Endpoints
# Standard departments (read-only): name, whether they apply to shots or assets,
# and the task types they own.
STANDARD_DEPARTMENTS = [
{"name": "layout", "type": "shot", "task_types": ["layout"]},
{"name": "animation", "type": "shot", "task_types": ["blocking", "primary_pass", "second_pass"]},
{"name": "simulation", "type": "shot", "task_types": ["simulation"]},
{"name": "lighting", "type": "shot", "task_types": ["lighting"]},
{"name": "composite", "type": "shot", "task_types": ["first_pass", "second_pass"]},
{"name": "modeling", "type": "asset", "task_types": ["modeling"]},
{"name": "rigging", "type": "asset", "task_types": ["rigging"]},
{"name": "surfacing", "type": "asset", "task_types": ["surfacing"]},
]
STANDARD_DEPARTMENT_NAMES = [d["name"] for d in STANDARD_DEPARTMENTS]
def _find_custom_department(custom_departments: list, name: str):
"""Find a custom department dict by name, or None."""
for department in custom_departments:
if department["name"] == name:
return department
return None
def _build_all_departments_response(db_project: Project):
"""Helper function to build AllDepartmentsResponse"""
from schemas.department import AllDepartmentsResponse, DepartmentInfo
custom_departments = db_project.custom_departments or []
standard_infos = [DepartmentInfo(**d) for d in STANDARD_DEPARTMENTS]
custom_infos = [DepartmentInfo(**d) for d in custom_departments]
return AllDepartmentsResponse(
departments=standard_infos + custom_infos,
standard_departments=standard_infos,
custom_departments=custom_infos
)
@router.get("/{project_id}/departments")
async def get_all_departments(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_with_db)
):
"""Get all departments (standard + custom) for a project"""
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments", status_code=status.HTTP_201_CREATED)
async def add_department(
project_id: int,
department_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a new custom department to a project"""
from schemas.department import CustomDepartmentCreate
try:
department_create = CustomDepartmentCreate(**department_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if department_create.department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' is a standard department and cannot be added as custom"
)
if _find_custom_department(custom_departments, department_create.department):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_create.department}' already exists"
)
custom_departments.append({
"name": department_create.department,
"type": department_create.department_type,
"task_types": department_create.task_types
})
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}")
async def update_department(
project_id: int,
department: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Update a custom department name, cascading the rename to members and tasks using it"""
from schemas.department import CustomDepartmentUpdate
from models.task import Task
try:
department_update = CustomDepartmentUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if department != department_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Department in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department_update.old_name)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department_update.old_name}' not found"
)
if department_update.new_name in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' is a standard department"
)
if (department_update.new_name != department_update.old_name
and _find_custom_department(custom_departments, department_update.new_name)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Department '{department_update.new_name}' already exists"
)
existing["name"] = department_update.new_name
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to project members using this department
members_to_update = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department_update.old_name
).all()
for member in members_to_update:
member.department_role = department_update.new_name
# Cascade rename to tasks using this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department_update.old_name
).all()
for task in tasks_to_update:
task.department = department_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update department"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}")
async def delete_department(
project_id: int,
department: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a custom department (blocked if any member or task is currently using it)"""
from models.task import Task
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments cannot be deleted"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
custom_departments = db_project.custom_departments or []
if not _find_custom_department(custom_departments, department):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
members_using_department = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.department_role == department
).all()
tasks_using_department = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department
).all()
if members_using_department or tasks_using_department:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete department '{department}' because it is currently in use",
"department": department,
"member_count": len(members_using_department),
"task_count": len(tasks_using_department)
}
)
custom_departments = [d for d in custom_departments if d["name"] != department]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department"
)
return _build_all_departments_response(db_project)
@router.post("/{project_id}/departments/{department}/task-types", status_code=status.HTTP_201_CREATED)
async def add_department_task_type(
project_id: int,
department: str,
task_type_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Add a task type to a custom department"""
from schemas.department import DepartmentTaskTypeCreate
try:
task_type_create = DepartmentTaskTypeCreate(**task_type_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_create.task_type in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_create.task_type}' already exists in department '{department}'"
)
existing["task_types"].append(task_type_create.task_type)
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add department task type"
)
return _build_all_departments_response(db_project)
@router.put("/{project_id}/departments/{department}/task-types/{task_type}")
async def rename_department_task_type(
project_id: int,
department: str,
task_type: str,
update_data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Rename a task type within a custom department, cascading the rename to matching tasks"""
from schemas.department import DepartmentTaskTypeUpdate
from models.task import Task
try:
task_type_update = DepartmentTaskTypeUpdate(**update_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
if task_type != task_type_update.old_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Task type in URL does not match old_name in request body"
)
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type_update.old_name not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type_update.old_name}' not found in department '{department}'"
)
if (task_type_update.new_name != task_type_update.old_name
and task_type_update.new_name in existing["task_types"]):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Task type '{task_type_update.new_name}' already exists in department '{department}'"
)
existing["task_types"] = [
task_type_update.new_name if t == task_type_update.old_name else t
for t in existing["task_types"]
]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
# Cascade rename to tasks using this task type within this department
tasks_to_update = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type_update.old_name
).all()
for task in tasks_to_update:
task.task_type = task_type_update.new_name
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to rename department task type"
)
return _build_all_departments_response(db_project)
@router.delete("/{project_id}/departments/{department}/task-types/{task_type}")
async def delete_department_task_type(
project_id: int,
department: str,
task_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Delete a task type from a custom department (blocked if any task is currently using it)"""
from models.task import Task
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if department in STANDARD_DEPARTMENT_NAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Standard departments' task types are fixed and cannot be modified"
)
custom_departments = db_project.custom_departments or []
existing = _find_custom_department(custom_departments, department)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Custom department '{department}' not found"
)
if task_type not in existing["task_types"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task type '{task_type}' not found in department '{department}'"
)
tasks_using_task_type = db.query(Task).filter(
Task.project_id == project_id,
Task.department == department,
Task.task_type == task_type
).all()
if tasks_using_task_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": f"Cannot delete task type '{task_type}' because it is currently in use",
"department": department,
"task_type": task_type,
"task_count": len(tasks_using_task_type)
}
)
existing["task_types"] = [t for t in existing["task_types"] if t != task_type]
db_project.custom_departments = custom_departments
flag_modified(db_project, 'custom_departments')
try:
db.commit()
db.refresh(db_project)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete department task type"
)
return _build_all_departments_response(db_project)
# Submission Configuration Endpoints
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
async def get_project_submission_config(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_with_db)
):
"""Get the project's per-task-type submission configuration"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
if current_user.role == UserRole.ARTIST:
member = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
).first()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this project"
)
config_data = project.submission_config_by_task_type
if isinstance(config_data, str):
try:
config_data = json.loads(config_data)
except (json.JSONDecodeError, TypeError):
config_data = {}
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
async def update_project_submission_config(
project_id: int,
submission_config: ProjectSubmissionConfig,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
):
"""Replace the project's per-task-type submission configuration"""
db_project = db.query(Project).filter(Project.id == project_id).first()
if not db_project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
all_types_response = _build_all_task_types_response(db_project)
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
for task_type in submission_config.config_by_task_type.keys():
if task_type not in valid_task_types:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"'{task_type}' is not a valid task type for this project"
)
db_project.submission_config_by_task_type = {
task_type: config.dict()
for task_type, config in submission_config.config_by_task_type.items()
}
try:
db.commit()
db.refresh(db_project)
except Exception:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update submission configuration"
)
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
# Project Thumbnail Management Endpoints
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
+5 -5
View File
@@ -7,7 +7,7 @@ from database import get_db
from models.task import Task, Submission, Review, TaskStatus
from models.user import User, UserRole
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse
from utils.auth import get_current_user_from_token, _get_user_from_db
from utils.auth import get_current_user_from_token, _get_user_from_db, require_permission
from utils.notifications import notification_service
router = APIRouter()
@@ -126,9 +126,9 @@ async def approve_submission(
submission_id: int,
review: ReviewCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_director_coordinator_or_admin)
current_user: User = Depends(require_permission('review', 'publish'))
):
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
"""Approve a submission. Requires review:publish permission (or admin)."""
submission = db.query(Submission).options(
joinedload(Submission.task)
@@ -189,9 +189,9 @@ async def request_retake(
submission_id: int,
review: ReviewCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_director_coordinator_or_admin)
current_user: User = Depends(require_permission('review', 'retake'))
):
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
"""Request a retake for a submission. Requires review:retake permission (or admin)."""
submission = db.query(Submission).options(
joinedload(Submission.task)
+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 models.user import User
router = APIRouter(prefix="/settings", tags=["settings"])
router = APIRouter(tags=["settings"])
# Default upload limit in MB (1GB)
DEFAULT_UPLOAD_LIMIT_MB = 1000
+18 -28
View File
@@ -12,7 +12,8 @@ from schemas.shot import (
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
BulkShotCreate, BulkShotResponse, TaskStatusInfo
)
from utils.auth import get_current_user_from_token
from utils.auth import get_current_user_from_token, require_permission
from utils.departments import find_owning_department
from services.shot_soft_deletion import ShotSoftDeletionService
router = APIRouter()
@@ -27,22 +28,6 @@ def get_current_user_with_db(
return _get_user_from_db(db, token_data["user_id"])
def require_coordinator_or_admin(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
):
"""Require coordinator or admin role."""
from utils.auth import _get_user_from_db
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
def check_episode_access(episode_id: int, current_user: User, db: Session):
"""Check if user has access to the episode and its project."""
# Debug logging
@@ -147,7 +132,7 @@ def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
return STANDARD_SHOT_TASK_TYPES + custom_types
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session, project: Project = None):
"""Create default tasks for a shot."""
created_tasks = []
@@ -161,7 +146,8 @@ def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session
shot_id=shot.id,
task_type=task_type,
name=task_name,
description=task_description
description=task_description,
department=find_owning_department(project, task_type) if project else None
)
db.add(task)
@@ -381,7 +367,7 @@ async def create_shot(
episode_id: int,
create_default_tasks: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'create'))
):
"""Create a new shot in an episode"""
# Check episode access
@@ -436,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
project = db.query(Project).filter(Project.id == episode.project_id).first()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
db.commit()
task_count = len(created_tasks)
@@ -452,7 +439,7 @@ async def create_shots_bulk(
bulk_shot: BulkShotCreate,
episode_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'create'))
):
"""Create multiple shots with naming pattern and default tasks"""
# Check episode access
@@ -514,6 +501,7 @@ async def create_shots_bulk(
created_shots = []
total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try:
# Create all shots - validation already done above
@@ -541,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested
task_count = 0
if bulk_shot.create_default_tasks:
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
task_count = len(created_tasks)
total_tasks_created += task_count
@@ -674,7 +662,7 @@ async def create_shot_task(
shot_id: int,
task_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task for a shot"""
# Exclude soft deleted shots
@@ -710,6 +698,7 @@ async def create_shot_task(
# Create the task
task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task(
project_id=shot.project_id,
episode_id=shot.episode_id,
@@ -717,7 +706,8 @@ async def create_shot_task(
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {shot.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
@@ -737,7 +727,7 @@ async def update_shot(
shot_id: int,
shot_update: ShotUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'edit'))
):
"""Update a shot"""
from sqlalchemy.orm import selectinload
@@ -818,7 +808,7 @@ async def update_shot(
async def get_shot_deletion_info(
shot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'delete'))
):
"""Get information about what will be deleted when deleting a shot"""
# Exclude soft deleted shots
@@ -868,7 +858,7 @@ async def get_shot_deletion_info(
async def delete_shot(
shot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'delete'))
):
"""Soft delete a shot and all its associated data"""
# Exclude soft deleted shots
+205 -46
View File
@@ -8,8 +8,8 @@ import json
from datetime import datetime
from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review
from models.user import User, UserRole, DepartmentRole
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
from models.user import User, UserRole
from models.project import Project, ProjectMember
from models.asset import Asset
from models.shot import Shot
@@ -19,12 +19,13 @@ from schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
TaskAttachmentCreate, TaskAttachmentResponse,
SubmissionCreate, SubmissionResponse,
SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo,
BulkStatusUpdate, BulkAssignment, BulkActionResult
)
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
from utils.notifications import notification_service
from utils.file_handler import file_handler
from utils.departments import find_owning_department
router = APIRouter()
@@ -93,20 +94,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo
return False
def require_admin_or_coordinator(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
):
"""Dependency to require admin permission or coordinator role."""
current_user = _get_user_from_db(db, token_data["user_id"])
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permission or Coordinator role required"
)
return current_user
def get_current_user(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
@@ -262,6 +249,8 @@ async def get_tasks(
"name": task.name,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"project_name": task.project.name if task.project else None,
@@ -343,6 +332,8 @@ async def get_my_tasks(
name=task.name,
task_type=task.task_type,
status=task.status,
department=task.department,
start_date=task.start_date,
deadline=task.deadline,
project_id=task.project_id,
project_name=task.project.name if task.project else "Unknown",
@@ -372,7 +363,7 @@ async def get_my_tasks(
async def create_task(
task: TaskCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task. Only coordinators and users with admin permission can create tasks."""
@@ -429,6 +420,12 @@ async def create_task(
detail=f"Invalid status '{task_data['status']}' for this project"
)
# If the task type belongs to a department, that department wins over
# anything explicitly submitted for `department`.
owning_department = find_owning_department(project, task_data.get('task_type'))
if owning_department:
task_data['department'] = owning_department
# Create task
db_task = Task(**task_data)
db.add(db_task)
@@ -520,7 +517,7 @@ async def bulk_update_task_status(
})
failed_count += 1
continue
elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin:
elif not user_has_permission(current_user, 'task', 'change_status', db):
errors.append({
"task_id": task_id,
"error": "Insufficient permissions"
@@ -597,7 +594,7 @@ async def bulk_update_task_status(
async def bulk_assign_tasks(
bulk_assignment: BulkAssignment,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('assignment', 'edit'))
):
"""
Assign multiple tasks to a user atomically.
@@ -694,6 +691,26 @@ async def bulk_assign_tasks(
)
@router.get("/submission-dates", response_model=List[SubmissionDateInfo])
async def get_submission_dates(
project_id: int = Query(..., description="Project ID to fetch submission dates for"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get a lightweight list of (task_id, submitted_at) for every non-deleted
submission on a project's tasks, for rendering submission markers (e.g.
on the Schedule Gantt chart) without fetching full submission payloads.
"""
submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter(
Task.project_id == project_id,
Task.deleted_at.is_(None),
Submission.deleted_at.is_(None)
).all()
return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions]
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: int,
@@ -732,6 +749,8 @@ async def get_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -780,7 +799,7 @@ async def update_task(
# Artists can only update status
if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
raise HTTPException(status_code=403, detail="Artists can only update task status")
elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
elif not user_has_permission(current_user, 'task', 'edit', db):
raise HTTPException(status_code=403, detail="Not authorized to update tasks")
# Verify assigned user if being updated
@@ -790,6 +809,10 @@ async def update_task(
if not assigned_user:
raise HTTPException(status_code=404, detail="Assigned user not found")
assignment_action = 'edit' if task.assigned_user_id else 'create'
if not user_has_permission(current_user, 'assignment', assignment_action, db):
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
# Check if user is a project member
project_member = db.query(ProjectMember).filter(
and_(
@@ -800,6 +823,8 @@ async def update_task(
if not project_member:
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
else:
if not user_has_permission(current_user, 'assignment', 'delete', db):
raise HTTPException(status_code=403, detail="Not authorized to unassign this task")
task_update.assigned_user_id = None
# Validate status if being updated
@@ -812,6 +837,14 @@ async def update_task(
detail=f"Invalid status '{update_data['status']}' for this project"
)
# If the task type is being changed to one owned by a department, that
# department wins over anything explicitly submitted for `department`.
if 'task_type' in update_data:
project = db.query(Project).filter(Project.id == task.project_id).first()
owning_department = find_owning_department(project, update_data['task_type'])
if owning_department:
update_data['department'] = owning_department
# Update task
for field, value in update_data.items():
setattr(task, field, value)
@@ -851,6 +884,8 @@ async def update_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -893,9 +928,10 @@ async def update_task_status(
raise HTTPException(status_code=404, detail="Task not found")
# Permission check
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this task")
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin:
elif not user_has_permission(current_user, 'task', 'change_status', db):
raise HTTPException(status_code=403, detail="Not authorized to update task status")
# Validate the status for the task's project
@@ -941,6 +977,8 @@ async def update_task_status(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -966,7 +1004,7 @@ async def assign_task(
task_id: int,
assignment: TaskAssignment,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(get_current_user)
):
"""Assign a task to a user with department role filtering."""
@@ -982,6 +1020,12 @@ async def assign_task(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Assigning a previously-unassigned task requires assignment:create;
# reassigning an already-assigned task requires assignment:edit.
assignment_action = 'edit' if task.assigned_user_id else 'create'
if not user_has_permission(current_user, 'assignment', assignment_action, db):
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
# Verify assigned user exists and is a project member
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
if not assigned_user:
@@ -1000,13 +1044,13 @@ async def assign_task(
# Check if user's department role matches task type (optional validation)
task_to_department_mapping = {
"layout": DepartmentRole.LAYOUT,
"animation": DepartmentRole.ANIMATION,
"lighting": DepartmentRole.LIGHTING,
"compositing": DepartmentRole.COMPOSITE,
"modeling": DepartmentRole.MODELING,
"rigging": DepartmentRole.RIGGING,
"surfacing": DepartmentRole.SURFACING,
"layout": "layout",
"animation": "animation",
"lighting": "lighting",
"compositing": "composite",
"modeling": "modeling",
"rigging": "rigging",
"surfacing": "surfacing",
"simulation": None # Simulation can be handled by multiple departments
}
@@ -1040,6 +1084,8 @@ async def assign_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"department": task.department,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -1064,7 +1110,7 @@ async def assign_task(
async def delete_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('task', 'delete'))
):
"""Delete a task. Only coordinators and users with admin permission can delete tasks."""
@@ -1119,6 +1165,17 @@ async def get_task_notes(
ProductionNote.deleted_at.is_(None)
).order_by(ProductionNote.created_at).all()
# Filter by note-type view permission (a note whose type the viewer can't
# see is dropped entirely - any reply to it is dropped too, since it
# would otherwise reference content the viewer isn't allowed to see)
can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db)
can_view_client = user_has_permission(current_user, 'note', 'view_client', db)
notes = [
note for note in notes
if (note.note_type == NoteType.INTERNAL and can_view_internal)
or (note.note_type == NoteType.CLIENT and can_view_client)
]
# Build threaded structure
notes_dict = {}
root_notes = []
@@ -1127,6 +1184,7 @@ async def get_task_notes(
note_data = {
"id": note.id,
"content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id,
"task_id": note.task_id,
"user_id": note.user_id,
@@ -1171,9 +1229,12 @@ async def create_task_note(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Artists can only add notes to their own tasks
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
# Artists can only add notes to their own tasks; everyone else needs note:create
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
elif not user_has_permission(current_user, 'note', 'create', db):
raise HTTPException(status_code=403, detail="Not authorized to add notes")
# Verify parent note exists if specified
if note.parent_note_id:
@@ -1192,6 +1253,7 @@ async def create_task_note(
task_id=task_id,
user_id=current_user.id,
content=note.content,
note_type=note.note_type,
parent_note_id=note.parent_note_id
)
db.add(db_note)
@@ -1206,6 +1268,7 @@ async def create_task_note(
note_data = {
"id": db_note.id,
"content": db_note.content,
"note_type": db_note.note_type,
"parent_note_id": db_note.parent_note_id,
"task_id": db_note.task_id,
"user_id": db_note.user_id,
@@ -1244,8 +1307,11 @@ async def update_task_note(
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# Users can only update their own notes, unless they have admin permission
if note.user_id != current_user.id and not current_user.is_admin:
# Editing your own note requires note:edit_self; editing someone else's requires note:edit_other
if not current_user.is_admin:
is_own = note.user_id == current_user.id
action = 'edit_self' if is_own else 'edit_other'
if not user_has_permission(current_user, 'note', action, db):
raise HTTPException(status_code=403, detail="Not authorized to update this note")
note.content = note_update.content
@@ -1255,6 +1321,7 @@ async def update_task_note(
note_data = {
"id": note.id,
"content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id,
"task_id": note.task_id,
"user_id": note.user_id,
@@ -1290,8 +1357,11 @@ async def delete_task_note(
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# Users can only delete their own notes, unless they have admin permission
if note.user_id != current_user.id and not current_user.is_admin:
# Deleting your own note requires note:delete_self; deleting someone else's requires note:delete_other
if not current_user.is_admin:
is_own = note.user_id == current_user.id
action = 'delete_self' if is_own else 'delete_other'
if not user_has_permission(current_user, 'note', action, db):
raise HTTPException(status_code=403, detail="Not authorized to delete this note")
db.delete(note)
@@ -1368,9 +1438,12 @@ async def upload_task_attachment(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Artists can only upload attachments to their own tasks
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
# Artists can only upload attachments to their own tasks; everyone else needs upload:create
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
elif not user_has_permission(current_user, 'upload', 'create', db):
raise HTTPException(status_code=403, detail="Not authorized to upload attachments")
# Validate file using file handler
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
@@ -1443,9 +1516,9 @@ async def delete_task_attachment(
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Users can only delete their own attachments, unless they're admin/coordinator
if (attachment.user_id != current_user.id and
not current_user.is_admin and current_user.role != UserRole.COORDINATOR):
# Users can only delete their own attachments, unless they have admin permission or upload:delete
if (attachment.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'upload', 'delete', db)):
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
# Delete file from filesystem using file handler
@@ -1534,8 +1607,9 @@ async def submit_work(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Only assigned artist can submit work
if task.assigned_user_id != current_user.id:
# Only the assigned artist can submit work, unless the caller holds submission:create
if (task.assigned_user_id != current_user.id
and not user_has_permission(current_user, 'submission', 'create', db)):
raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
# Validate file using file handler
@@ -1600,3 +1674,88 @@ async def submit_work(
}
return SubmissionResponse(**submission_data)
@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse)
async def update_task_submission(
task_id: int,
submission_id: int,
submission_update: SubmissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a submission's notes. Users can only update their own submissions."""
submission = db.query(Submission).options(
joinedload(Submission.user)
).filter(
Submission.id == submission_id,
Submission.task_id == task_id,
Submission.deleted_at.is_(None)
).first()
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
# Editing your own submission requires submission:edit_self; editing
# someone else's requires submission:edit_other
if not current_user.is_admin:
is_own = submission.user_id == current_user.id
action = 'edit_self' if is_own else 'edit_other'
if not user_has_permission(current_user, 'submission', action, db):
raise HTTPException(status_code=403, detail="Not authorized to update this submission")
if submission_update.notes is not None:
submission.notes = submission_update.notes
db.commit()
db.refresh(submission)
submission_data = {
"id": submission.id,
"task_id": submission.task_id,
"user_id": submission.user_id,
"file_path": submission.file_path,
"file_name": submission.file_name,
"version_number": submission.version_number,
"notes": submission.notes,
"submitted_at": submission.submitted_at,
"user_first_name": submission.user.first_name,
"user_last_name": submission.user.last_name,
"latest_review": None,
"download_url": f"/files/submissions/{submission.id}",
"thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None,
"stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None
}
return SubmissionResponse(**submission_data)
@router.delete("/{task_id}/submissions/{submission_id}")
async def delete_task_submission(
task_id: int,
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Soft delete a submission. Users can only delete their own submissions."""
submission = db.query(Submission).filter(
Submission.id == submission_id,
Submission.task_id == task_id,
Submission.deleted_at.is_(None)
).first()
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
# Deleting your own submission requires submission:delete_self; deleting
# someone else's requires submission:delete_other
if not current_user.is_admin:
is_own = submission.user_id == current_user.id
action = 'delete_self' if is_own else 'delete_other'
if not user_has_permission(current_user, 'submission', action, db):
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
submission.deleted_at = datetime.utcnow()
submission.deleted_by = current_user.id
db.commit()
return {"message": "Submission deleted successfully"}
+49 -3
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from typing import List, Optional
from passlib.context import CryptContext
from pathlib import Path
@@ -12,7 +12,9 @@ from database import get_db
from models.user import User, UserRole
from models.project import ProjectMember
from models.task import Task
from models.role import Role
from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange
from schemas.role import UserRolesUpdate
from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -106,6 +108,42 @@ async def update_user_role(
}
@router.put("/{user_id}/roles", response_model=dict)
async def update_user_roles(
user_id: int,
roles_data: UserRolesUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission_with_db)
):
"""Replace a user's full set of assigned custom roles (Admin permission required).
This is the new multi-role system and is independent of the legacy
single `role` field updated by PUT /{user_id}/role above.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
roles = db.query(Role).filter(Role.id.in_(roles_data.role_ids)).all()
if len(roles) != len(set(roles_data.role_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more role_ids are invalid"
)
user.roles = roles
db.commit()
return {
"message": f"User {user.email} roles updated",
"user_id": user.id,
"role_ids": [role.id for role in user.roles]
}
@router.put("/{user_id}/admin", response_model=dict)
async def update_user_admin_permission(
user_id: int,
@@ -149,7 +187,7 @@ async def list_users(
current_user: User = Depends(require_admin_or_coordinator)
):
"""List all users (Admin and Coordinator only)."""
users = db.query(User).offset(skip).limit(limit).all()
users = db.query(User).options(selectinload(User.roles)).offset(skip).limit(limit).all()
return users
@@ -169,7 +207,10 @@ async def get_current_user_profile(
db: Session = Depends(get_db)
):
"""Get current user's profile."""
from utils.auth import compute_effective_permissions
current_user = _get_user_from_db(db, token_data["user_id"])
current_user.permissions = compute_effective_permissions(current_user, db)
return current_user
@@ -214,7 +255,7 @@ async def get_user(
current_user: User = Depends(require_admin_or_coordinator)
):
"""Get user by ID (Admin and Coordinator only)."""
user = db.query(User).filter(User.id == user_id).first()
user = db.query(User).options(selectinload(User.roles)).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -256,6 +297,11 @@ async def admin_create_user(
db.commit()
db.refresh(new_user)
from utils.auth import link_system_role
link_system_role(new_user, db)
db.commit()
db.refresh(new_user)
return new_user
+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
from models.project import ProjectStatus, ProjectType
from models.user import DepartmentRole
# Technical Specifications Schemas
@@ -67,16 +66,6 @@ class ProjectTechnicalSpecs(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
# Default delivery movie specifications per department
DEFAULT_DELIVERY_MOVIE_SPECS = {
@@ -135,17 +124,6 @@ class ProjectBase(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return {}
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectCreate(ProjectBase):
pass
@@ -178,20 +156,9 @@ class ProjectUpdate(BaseModel):
raise ValueError('Delivery image resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('delivery_movie_specs_by_department')
def validate_delivery_movie_specs_by_department(cls, v):
if v is None:
return v
allowed_departments = ['layout', 'animation', 'lighting', 'composite', 'modeling', 'rigging', 'surfacing']
for dept in v.keys():
if dept not in allowed_departments:
raise ValueError(f'Department must be one of: {", ".join(allowed_departments)}')
return v
class ProjectMemberBase(BaseModel):
user_id: int
department_role: Optional[DepartmentRole] = None
department_role: Optional[str] = None
class ProjectMemberCreate(ProjectMemberBase):
@@ -199,7 +166,7 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberUpdate(BaseModel):
department_role: Optional[DepartmentRole] = None
department_role: Optional[str] = None
class ProjectMemberResponse(ProjectMemberBase):
+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 enum import Enum
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType
from models.user import DepartmentRole
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
class TaskBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
task_type: str # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
class TaskCreate(TaskBase):
@@ -27,8 +28,10 @@ class TaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
assigned_user_id: Optional[int] = None
@@ -68,6 +71,8 @@ class TaskListResponse(BaseModel):
name: str
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
department: Optional[str] = None # Standard or project-custom department, independent of assignee
start_date: Optional[date] = None
deadline: Optional[date] = None
project_id: int
project_name: str
@@ -94,7 +99,7 @@ class ProductionNoteBase(BaseModel):
class ProductionNoteCreate(ProductionNoteBase):
pass
note_type: NoteType = NoteType.INTERNAL
class ProductionNoteUpdate(BaseModel):
@@ -105,6 +110,7 @@ class ProductionNoteResponse(ProductionNoteBase):
id: int
task_id: int
user_id: int
note_type: NoteType
created_at: datetime
updated_at: datetime
@@ -162,6 +168,10 @@ class SubmissionCreate(SubmissionBase):
pass
class SubmissionUpdate(BaseModel):
notes: Optional[str] = None
class SubmissionResponse(SubmissionBase):
id: int
task_id: int
@@ -187,6 +197,15 @@ class SubmissionResponse(SubmissionBase):
from_attributes = True
class SubmissionDateInfo(BaseModel):
"""Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers)."""
task_id: int
submitted_at: datetime
class Config:
from_attributes = True
# Review schemas
class ReviewBase(BaseModel):
decision: ReviewDecision
@@ -214,7 +233,7 @@ class ReviewResponse(ReviewBase):
# Bulk action schemas
class BulkStatusUpdate(BaseModel):
task_ids: List[int] = Field(..., min_length=1)
status: TaskStatus
status: str # Changed from TaskStatus enum to str to support custom statuses
class BulkAssignment(BaseModel):
+4 -1
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from typing import Optional, List
from datetime import datetime
from models.user import UserRole
from schemas.role import RoleSummary
class UserBase(BaseModel):
@@ -30,6 +31,8 @@ class UserResponse(UserBase):
avatar_url: Optional[str] = None
created_at: datetime
updated_at: datetime
roles: List[RoleSummary] = []
permissions: Optional[List[str]] = None
class Config:
from_attributes = True
+95
View File
@@ -244,6 +244,101 @@ def require_admin_permission():
return admin_checker
def compute_effective_permissions(current_user, db: Session) -> list:
"""Return the current user's effective permissions as "resource:action" strings.
is_admin gets every permission in the catalog; otherwise the union of all
permissions granted by any of the user's assigned roles (most-permissive-wins).
"""
from models.role import Permission, role_permissions, user_roles
if current_user.is_admin:
rows = db.query(Permission.resource, Permission.action).all()
else:
rows = (
db.query(Permission.resource, Permission.action)
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
.filter(user_roles.c.user_id == current_user.id)
.distinct()
.all()
)
return [f"{resource}:{action}" for resource, action in rows]
def link_system_role(user, db: Session) -> None:
"""Link a newly-created user to the system Role matching their legacy
`role` value, mirroring the one-time migration backfill so new users
start with the same baseline permissions as already-migrated ones.
No-op if the matching system role doesn't exist or is already linked.
Caller is responsible for commit/refresh.
"""
from models.role import Role
role = db.query(Role).filter(Role.name == user.role.value, Role.is_system == True).first()
if role and role not in user.roles:
user.roles.append(role)
def user_has_permission(current_user, resource: str, action: str, db: Session) -> bool:
"""Most-permissive-wins check across all of current_user's assigned roles.
is_admin is a universal bypass and never needs a role/permission row.
"""
if current_user.is_admin:
return True
from models.role import Permission, role_permissions, user_roles
exists = (
db.query(Permission.id)
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
.filter(
user_roles.c.user_id == current_user.id,
Permission.resource == resource,
Permission.action == action,
)
.first()
)
return exists is not None
def require_permission(resource: str, action: str):
"""Dependency factory requiring the current user to hold resource:action
via any assigned role (or be an admin). Usage: Depends(require_permission('shot', 'create'))
"""
def permission_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
if not user_has_permission(current_user, resource, action, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {resource}:{action}"
)
return current_user
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if not user_has_permission(current_user, resource, action, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {resource}:{action}"
)
return current_user
return permission_checker
def create_role_dependency(required_roles: list):
"""Create a dependency that requires specific user roles with proper DB injection."""
def role_checker(
+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_FORMATS = {
# Video formats
'.mov', '.mp4', '.avi', '.mkv', '.webm',
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
# Image formats
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
# Document formats
'.pdf', '.txt', '.doc', '.docx',
# Archive formats
'.zip', '.rar', '.7z'
'.zip', '.rar', '.7z',
# Scene formats
'.ma', '.usd', '.usda', '.usdc'
}
# File size limits (in bytes)
@@ -35,7 +37,7 @@ class FileHandler:
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
# Movie file extensions that should use global upload limit
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
# Thumbnail settings
THUMBNAIL_SIZE = (200, 200)
+137
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0",
"mediainfo.js": "^0.3.7",
"pinia": "^2.1.7",
"reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2",
@@ -2789,6 +2790,60 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4053,6 +4108,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -4817,6 +4881,21 @@
"node": ">= 0.8"
}
},
"node_modules/mediainfo.js": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz",
"integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==",
"license": "BSD-2-Clause",
"dependencies": {
"yargs": "^18.0.0"
},
"bin": {
"mediainfo.js": "dist/esm/cli.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
@@ -7595,12 +7674,70 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.294.0",
"mediainfo.js": "^0.3.7",
"pinia": "^2.1.7",
"reka-ui": "^2.6.1",
"shadcn-vue": "^2.3.2",
@@ -34,7 +34,7 @@
</div>
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
<ActivityIcon class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No activity yet</p>
</div>
+18 -51
View File
@@ -1,9 +1,9 @@
<template>
<div class="relative h-full">
<!-- Main Content (Full Width) -->
<div class="space-y-4">
<!-- Toolbar - Sticky -->
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="relative h-full flex flex-col">
<!-- Main Content -->
<div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<AssetTableToolbar
v-if="customTaskTypesLoaded"
:view-mode="viewMode"
@@ -36,7 +36,7 @@
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
@@ -46,7 +46,7 @@
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12">
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
@@ -61,7 +61,7 @@
v-else-if="
filteredAssets.length === 0 && !searchQuery && !selectedCategory
"
class="text-center py-12"
class="text-center py-12 px-4 sm:px-6"
>
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
@@ -75,7 +75,7 @@
</div>
<!-- No Results State -->
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
<p class="text-muted-foreground mb-4">
@@ -85,11 +85,11 @@
</div>
<!-- Assets Grid/List -->
<div v-else>
<div v-else class="flex-1 min-h-0">
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
>
<AssetCard
v-for="asset in filteredAssets"
@@ -117,7 +117,7 @@
@update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick"
/>
<div v-else class="flex items-center justify-center py-12">
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading table...</span>
@@ -173,43 +173,11 @@
@confirm-delete="handleDeleteAsset"
/>
</div>
<!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
<!-- Asset Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedAsset)"
v-model:mobile-open="showMobileDetail"
>
<div
v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</div>
</Transition>
<!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
@@ -228,8 +196,7 @@
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</SheetContent>
</Sheet>
</DetailPanelOverlay>
</div>
</template>
@@ -253,7 +220,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
@@ -1,64 +1,48 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading asset details...</span>
</div>
</div>
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadAssetDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<DetailPanelError
v-else-if="error"
title="Failed to load asset"
:message="error"
@retry="loadAssetDetails"
/>
<!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': asset.deleted_at }">{{ asset.name }}</h2>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="references">
References
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ references.length }}
</Badge>
</TabsTrigger>
@@ -271,7 +255,13 @@
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
<EntityNotes
:key="assetId"
:tasks="tasks"
:notes="notes"
:submissions="submissions"
@notes-updated="loadNotes"
/>
</TabsContent>
<!-- References Tab -->
@@ -286,20 +276,24 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import AssetNotes from './AssetNotes.vue'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService } from '@/services/task'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task {
id: number
@@ -328,10 +322,12 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state
const asset = ref<Asset | null>(null)
const notes = ref<any[]>([])
const notes = ref<ProductionNote[]>([])
const submissions = ref<Submission[]>([])
const references = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -369,9 +365,15 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
// Task types offered in "Add Task" include the flat asset task type list
// plus every task type owned by an asset department (e.g. Modeling's own
// task types), deduped against types the asset already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
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(() => {
@@ -400,6 +402,7 @@ const loadAssetDetails = async () => {
isLoading.value = true
error.value = null
asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) {
@@ -418,28 +421,18 @@ const loadAssetDetails = async () => {
}
const loadNotes = async () => {
if (tasks.value.length === 0) {
notes.value = []
submissions.value = []
return
}
try {
// Load notes from all tasks associated with this asset
const { taskService } = await import('@/services/task')
const allNotes: any[] = []
for (const task of tasks.value) {
if (task.id) {
const taskNotes = await taskService.getTaskNotes(task.id)
// Add task info to each note for context
const notesWithContext = taskNotes.map(note => ({
...note,
task_name: task.name,
task_type: task.task_type
}))
allNotes.push(...notesWithContext)
}
}
// Sort by date (newest first)
notes.value = allNotes.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
notes.value = notesByTask.flat()
submissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load notes:', err)
}
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-col h-full">
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet for this asset's tasks.</p>
</div>
<div
v-for="note in notes"
:key="note.id"
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">{{ note.author_name }}</span>
<Badge variant="outline" class="text-xs">
{{ formatTaskType(note.task_type) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ note.task_name }} {{ formatDate(note.created_at) }}
</p>
</div>
</div>
<!-- Note Content -->
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MessageSquarePlus } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
const props = defineProps<{
assetId: number
notes: any[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
@@ -43,8 +43,8 @@
</div>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0 flex-shrink-0"
size="icon-sm"
class="flex-shrink-0"
@click="downloadFile(reference)"
>
<Download class="h-4 w-4" />
@@ -5,26 +5,11 @@
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
</div>
<SegmentedToggle
:options="viewModeOptions"
:model-value="viewMode"
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list')"
/>
<!-- Category Filter -->
<Popover>
@@ -47,41 +32,23 @@
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:category-filter', 'all')"
:model-value="categoryFilter === 'all'"
@update:model-value="$emit('update:category-filter', 'all')"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Categories</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="category in categories"
:key="category.value"
:value="category.value"
@select="$emit('update:category-filter', category.value)"
:model-value="categoryFilter === category.value"
@update:model-value="$emit('update:category-filter', category.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === category.value
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<component :is="category.icon" class="h-4 w-4 mr-2" />
<span>{{ category.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -97,78 +64,12 @@
/>
<!-- Column Visibility Control (only for list view) -->
<Popover v-if="viewMode === 'list'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ColumnToggleList
v-if="viewMode === 'list'"
:columns="columnToggleOptions"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
<!-- Task Columns Toggle Button (only for list view) -->
<Button
@@ -184,28 +85,19 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
<Button
<DetailPanelToggleButton
v-if="viewMode === 'list'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for list view) -->
<Button
v-if="viewMode === 'list'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
@@ -213,16 +105,7 @@
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -241,9 +124,8 @@
<!-- Thumbnail Toggle -->
<Button
variant="outline"
size="sm"
size="icon-sm"
@click="$emit('update:show-thumbnails', !showThumbnails)"
class="h-8 w-8 p-0"
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
>
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
@@ -251,7 +133,7 @@
</Button>
<!-- Create Asset Button -->
<Button @click="$emit('create-asset')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-asset')" size="icon-sm" title="Create Asset">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -263,7 +145,7 @@
import { computed } from 'vue'
import {
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
ListTodo, ListX,
Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -279,11 +161,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusFilter from './TaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Asset, AssetCategory } from '@/services/asset'
@@ -344,9 +230,19 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -355,21 +251,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
@@ -1,13 +1,13 @@
<template>
<div class="space-y-4 px-4">
<div class="rounded-md border">
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns">
<div v-if="hasRows" class="flex">
<!-- Left pane: frozen columns, no horizontal scroll -->
<div class="flex-shrink-0 border-r">
<div v-if="hasRows" class="flex h-full">
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
@@ -29,7 +29,7 @@
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@@ -47,10 +47,10 @@
</table>
</div>
<!-- Right pane: movable columns, own horizontal scrollbar -->
<div class="flex-1 min-w-0 overflow-x-auto">
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
@@ -75,7 +75,7 @@
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@@ -100,7 +100,7 @@
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -225,6 +225,19 @@ const movableCells = (row: Row<Asset>) =>
const hasRows = computed(() => table.getRowModel().rows.length > 0)
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
const leftPane = ref<HTMLElement>()
const rightPane = ref<HTMLElement>()
let syncing = false
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
if (syncing || !from || !to) return
syncing = true
to.scrollTop = from.scrollTop
requestAnimationFrame(() => { syncing = false })
}
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
const emit = defineEmits<{
'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState]
@@ -405,4 +418,13 @@ watch(
-ms-user-select: text;
user-select: text;
}
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
</style>
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
@@ -202,11 +203,12 @@ const { getAvatarUrl } = useAvatarUrl()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
@@ -306,17 +308,12 @@ const fetchStatuses = async () => {
}
}
// Load project members
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
@@ -400,10 +397,9 @@ onMounted(() => {
loadProjectMembers()
})
// Refetch statuses when projectId changes
// Refetch statuses and members when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
loadProjectMembers()
})
</script>
+1 -2
View File
@@ -304,8 +304,7 @@ export const createAssetColumns = (
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -83,7 +83,7 @@ import {
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { episodeService, type Episode } from '@/services/episode'
interface Props {
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const router = useRouter()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// Reactive state
const episodes = ref<Episode[]>([])
@@ -108,11 +108,7 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const canCreateEpisodes = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
const sortedEpisodes = computed(() => {
return [...episodes.value].sort((a, b) => {
@@ -168,7 +168,7 @@
</p>
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
</div>
+81 -4
View File
@@ -8,13 +8,53 @@
<Breadcrumb class="flex-1">
<BreadcrumbList>
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
{{ project.name }}
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<router-link to="/projects">All Projects</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
{{ tabItem.label }}
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
{{ crumb.label }}
</BreadcrumbLink>
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
{{ crumb.label }}
</BreadcrumbPage>
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -76,7 +116,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
@@ -97,10 +137,11 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { User, Settings, LogOut } from 'lucide-vue-next'
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
import NotificationCenter from './NotificationCenter.vue'
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
// Generate breadcrumbs based on current route with enhanced context
const breadcrumbs = ref<BreadcrumbData[]>([])
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
const projectIdParam = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
})
const currentTab = computed(() => route.meta?.tab as string | undefined)
const projectTabItems = [
{ tab: 'overview', label: 'Overview', path: '' },
{ tab: 'shots', label: 'Shots', path: '/shots' },
{ tab: 'assets', label: 'Assets', path: '/assets' },
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
{ tab: 'settings', label: 'Settings', path: '/settings' }
]
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
const projectsStore = useProjectsStore()
const projectsForSwitcher = computed(() => projectsStore.projects)
onMounted(() => {
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
projectsStore.fetchProjects()
}
})
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
// so the trailing ">" separator can be skipped for it.
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
if (crumb.isTabCrumb) return !!projectIdParam.value
return false
}
const updateBreadcrumbs = async () => {
try {
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
+103 -2
View File
@@ -27,12 +27,65 @@
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
<!-- Collapsed sidebar + Projects (with an active project): hovering reveals sub-links,
while the icon itself still navigates to /projects like any other nav item. -->
<div
v-if="item.title === 'Projects' && isCollapsed && projectTabs.length > 0"
@mouseenter="showProjectFlyout = true"
@mouseleave="showProjectFlyout = false"
>
<Popover :open="showProjectFlyout">
<PopoverAnchor>
<SidebarMenuButton as-child :title="item.title">
<router-link :to="item.url" class="flex items-center gap-2">
<component :is="item.icon" class="size-4" />
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
</router-link>
</SidebarMenuButton>
</PopoverAnchor>
<PopoverContent
side="right"
align="start"
class="w-48 p-1"
@open-auto-focus.prevent
@mouseenter="showProjectFlyout = true"
@mouseleave="showProjectFlyout = false"
>
<router-link
v-for="tab in projectTabs"
:key="tab.id"
:to="tab.route"
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
:class="activeProjectTab === tab.id && 'bg-accent text-accent-foreground'"
@click="showProjectFlyout = false"
>
<component :is="tab.icon" class="size-4" />
<span>{{ tab.label }}</span>
</router-link>
</PopoverContent>
</Popover>
</div>
<template v-else>
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
<router-link :to="item.url" class="flex items-center gap-2">
<component :is="item.icon" class="size-4" />
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
</router-link>
</SidebarMenuButton>
<!-- Project sub-navigation - shown under "Projects" when inside a specific project -->
<SidebarMenuSub v-if="item.title === 'Projects' && projectTabs.length > 0">
<SidebarMenuSubItem v-for="tab in projectTabs" :key="tab.id">
<SidebarMenuSubButton as-child :is-active="activeProjectTab === tab.id">
<router-link :to="tab.route" class="flex items-center gap-2">
<component :is="tab.icon" class="size-4" />
<span class="truncate">{{ tab.label }}</span>
</router-link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</template>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
@@ -93,8 +146,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import {
Sidebar,
SidebarContent,
@@ -105,6 +159,9 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
SidebarProps
} from '@/components/ui/sidebar'
@@ -120,15 +177,24 @@ import {
Database,
BarChart3,
RotateCcw,
LayoutDashboard,
Camera,
Package,
ListTodo,
ShieldCheck,
GanttChartSquare,
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import ProjectSwitcher from './ProjectSwitcher.vue'
import UserMenu from './UserMenu.vue'
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { state } = useSidebar()
const route = useRoute()
const showProjectFlyout = ref(false)
const user = computed(() => authStore.user)
const userRole = computed(() => authStore.user?.role || 'artist')
@@ -140,6 +206,40 @@ const isOnShotPage = computed(() => {
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>(), {
collapsible: "icon",
})
@@ -150,7 +250,7 @@ const navigationItems = computed(() => {
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
]
if (userRole.value === 'coordinator' || authStore.isAdmin) {
if (isCoordinatorOrAdmin.value) {
baseItems.push(
{ title: 'Projects', url: '/projects', icon: FolderOpen },
{ title: 'Team', url: '/users', icon: Users }
@@ -175,6 +275,7 @@ const navigationItems = computed(() => {
// Admin-specific navigation items
const adminItems = computed(() => [
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
{ title: 'Role Management', url: '/admin/roles', icon: ShieldCheck },
])
// Developer-specific navigation items
@@ -135,11 +135,13 @@ import {
} from "@/components/ui/sidebar";
import { useAuthStore } from "@/stores/auth";
import { useProjectsStore } from "@/stores/projects";
import { usePermission } from "@/composables/usePermission";
const router = useRouter();
const { isMobile } = useSidebar();
const authStore = useAuthStore();
const projectsStore = useProjectsStore();
const { isCoordinatorOrAdmin } = usePermission();
// Get projects and active project from store
const projects = computed(() => projectsStore.availableProjects);
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
const error = computed(() => projectsStore.error);
// Check if user can create projects
const canCreateProjects = computed(() => {
const user = authStore.user;
return user?.is_admin || user?.role === "coordinator";
});
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
const setActiveProject = (project: any) => {
projectsStore.setActiveProject(project);
@@ -104,7 +104,7 @@
import { computed } from 'vue'
import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench,
surfacing: Paintbrush
}
return icons[department] || Box
// Fallback for project-custom departments not in the standard icon map above
return icons[department] || Tag
}
const getFrameRateLabel = (frameRate: number) => {
@@ -83,7 +83,7 @@
<!-- Actions -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -235,6 +235,7 @@ import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user'
import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth'
interface Props {
@@ -248,16 +249,14 @@ const emit = defineEmits<{
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = [
{ value: 'layout', label: 'Layout' },
{ value: 'animation', label: 'Animation' },
{ value: 'lighting', label: 'Lighting' },
{ value: 'composite', label: 'Composite' },
{ value: 'modeling', label: 'Modeling' },
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
const departmentRoles = computed(() => {
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
value: department,
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}))
})
// State
const members = ref<ProjectMember[]>([])
@@ -425,5 +424,6 @@ const closeAddDialog = () => {
// Lifecycle
onMounted(() => {
loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
})
</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>
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import { notificationService } from '@/services/notifications'
@@ -97,7 +97,7 @@ interface Props {
const props = defineProps<Props>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { toast } = useToast()
// State
@@ -108,11 +108,7 @@ const isEditing = ref(false)
const showNotification = ref(false)
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
// Methods
const loadSpecs = async () => {
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
@@ -73,7 +73,7 @@ interface Emits {
const props = defineProps<Props>()
defineEmits<Emits>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// State
const isOpen = ref(props.defaultOpen || false)
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
const specs = ref<ProjectTechnicalSpecs | undefined>()
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
const hasSpecs = computed(() => {
if (!specs.value) return false
@@ -0,0 +1,230 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
<DialogDescription>
{{ role?.is_system
? 'System role permissions are editable, but the name and description cannot be changed.'
: 'Define a role and the permissions it grants.' }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
<div class="space-y-2">
<Label for="role_name">Name</Label>
<Input
id="role_name"
v-model="formData.name"
placeholder="e.g. Reviewer"
required
:disabled="role?.is_system"
/>
</div>
<div class="space-y-2">
<Label for="role_description">Description</Label>
<Input
id="role_description"
v-model="formData.description"
placeholder="What this role is for"
:disabled="role?.is_system"
/>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col gap-2">
<div class="flex items-center justify-between flex-shrink-0">
<Label>Permissions</Label>
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
</div>
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
<div class="space-y-2">
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-sm font-medium capitalize">
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
{{ resource }}
</div>
<button
type="button"
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
@click="toggleAllForResource(resource)"
>
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
</button>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
<label
v-for="perm in permissionsForResource(resource)"
:key="perm.id"
class="flex items-center gap-1.5 text-sm cursor-pointer"
:title="perm.description || undefined"
>
<Checkbox
:model-value="selectedPermissionIds.has(perm.id)"
@update:model-value="(val) => togglePermission(perm.id, !!val)"
/>
{{ actionLabel(perm.action) }}
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
<DialogFooter class="flex-shrink-0">
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
} from 'lucide-vue-next'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import type { Role, Permission } from '@/services/role'
interface Props {
open: boolean
role: Role | null
permissions: Permission[]
saving?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
}>()
const formData = ref({ name: '', description: '' })
const selectedPermissionIds = ref<Set<number>>(new Set())
const formError = ref('')
// Resources grouped into scannable sections, in a fixed, deliberate order
// (not alphabetical) so related concepts sit together.
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
{ title: 'Review', resources: ['review'] },
{ title: 'Notes', resources: ['note'] },
]
const RESOURCE_ICONS: Record<string, any> = {
asset: Package,
shot: Camera,
task: ListTodo,
assignment: UserCheck,
submission: UploadCloud,
upload: Paperclip,
review: CheckCircle2,
note: MessageSquare,
}
const ACTION_LABELS: Record<string, string> = {
create: 'Create',
edit: 'Edit',
delete: 'Delete',
publish: 'Approve',
retake: 'Request Retake',
view_internal: 'View Internal',
view_client: 'View Client',
change_status: 'Change Status',
edit_self: 'Edit Own',
delete_self: 'Delete Own',
edit_other: "Edit Others'",
delete_other: "Delete Others'",
}
function resourceIcon(resource: string) {
return RESOURCE_ICONS[resource] ?? Shield
}
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action
}
// Only show groups/resources that actually have permissions in the catalog,
// and fall back to a catch-all group for any future resource not yet
// assigned to a section above (so nothing silently disappears from the UI).
const permissionGroups = computed(() => {
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
const groups = RESOURCE_GROUPS
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
.filter(g => g.resources.length > 0)
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
if (otherResources.length > 0) {
groups.push({ title: 'Other', resources: otherResources })
}
return groups
})
function permissionsForResource(resource: string): Permission[] {
return props.permissions.filter(p => p.resource === resource)
}
function allSelectedForResource(resource: string): boolean {
const perms = permissionsForResource(resource)
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
}
function toggleAllForResource(resource: string) {
const perms = permissionsForResource(resource)
const shouldSelect = !allSelectedForResource(resource)
for (const perm of perms) {
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
else selectedPermissionIds.value.delete(perm.id)
}
}
function togglePermission(id: number, checked: boolean) {
if (checked) selectedPermissionIds.value.add(id)
else selectedPermissionIds.value.delete(id)
}
watch(() => props.open, (isOpen) => {
if (!isOpen) return
formError.value = ''
formData.value = {
name: props.role?.name ?? '',
description: props.role?.description ?? ''
}
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
})
function handleSubmit() {
formError.value = ''
if (!props.role && !formData.value.name.trim()) {
formError.value = 'Name is required'
return
}
const payload = props.role?.is_system
? { permission_ids: [...selectedPermissionIds.value] }
: {
name: formData.value.name,
description: formData.value.description || undefined,
permission_ids: [...selectedPermissionIds.value]
}
emit('saved', payload)
}
</script>
@@ -0,0 +1,810 @@
<template>
<div class="h-full flex flex-col">
<!-- Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 border-b">
<div class="flex flex-wrap items-center gap-3">
<Select
:model-value="episodeFilter === null ? 'all' : String(episodeFilter)"
@update:model-value="handleEpisodeFilterChange"
>
<SelectTrigger class="w-48 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Episodes</SelectItem>
<SelectItem v-for="ep in episodeOptions" :key="ep.id" :value="String(ep.id)">{{ ep.name }}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-8" @click="toggleAllGroups">
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="option in GROUP_BY_OPTIONS"
:key="option"
:variant="groupBy === option ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs"
@click="groupBy = option"
>
{{ groupByLabel(option) }}
</Button>
</div>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="scale in SCALES"
:key="scale"
:variant="viewScale === scale ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs capitalize"
@click="viewScale = scale"
>
{{ scale }}
</Button>
</div>
<div class="flex items-center gap-1.5">
<div class="w-36">
<DatePicker v-model="manualRangeStart" placeholder="Start date" />
</div>
<span class="text-xs text-muted-foreground">to</span>
<div class="w-36">
<DatePicker v-model="manualRangeEnd" placeholder="End date" :min="manualRangeStart" />
</div>
<Button
v-if="manualRangeStart || manualRangeEnd"
variant="ghost"
size="sm"
class="h-8 px-2 text-xs"
@click="manualRangeStart = ''; manualRangeEnd = ''"
>
Reset
</Button>
</div>
</div>
<div v-if="legendStatuses.length > 0" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span v-for="status in legendStatuses" :key="status.id" class="flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm flex-shrink-0" :style="{ backgroundColor: status.color }"></span>
{{ status.name }}
</span>
</div>
</div>
<div v-if="isLoading" class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Loading schedule...
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }}
</div>
<div v-else class="flex-1 flex flex-col overflow-hidden">
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="rowGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
</div>
<div v-else class="flex-1 flex overflow-hidden">
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
<div
ref="frozenPaneRef"
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
:style="{ width: FROZEN_WIDTH + 'px' }"
@scroll="handleFrozenScroll"
>
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
Task Type / Task
</div>
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
Task Status
</div>
</div>
<div v-for="group in rowGroups" :key="group.key">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.key)"
>
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
<component
:is="isCollapsed(group.key) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ group.label }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
</div>
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
{{ taskRowLabel(task) }}
</div>
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
<EditableTaskStatus
:task-id="task.id"
:status="task.status"
:project-id="projectId"
show-assignee
:assigned-user-id="task.assigned_user_id"
@status-updated="handleStatusUpdated"
@assignment-updated="handleAssignmentUpdated"
/>
</div>
</div>
</template>
</div>
</div>
<!-- Timeline pane: date axis + bars, scrolls both ways -->
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<!-- Date axis header: month row on top, date-number row below -->
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="relative h-5 border-b">
<div
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
{{ marker.label }}
</div>
</div>
<div class="relative h-6">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
>
{{ marker.label }}
</div>
</div>
</div>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<div v-for="group in rowGroups" :key="group.key">
<div
class="relative border-b bg-muted/40 cursor-pointer"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.key)"
>
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
class="relative border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
:style="taskBarStyle(task)"
:title="taskBarTitle(task)"
>
<div
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
style="right: 6px;"
@mousedown="startDrag($event, task, 'move')"
></div>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-start')"
></div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-end')"
></div>
<span
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
>
{{ taskRowLabel(task) }}
</span>
</div>
<!-- Submission date markers -->
<div
v-for="date in submissionDatesFor(task.id)"
:key="date"
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
:title="`Submitted ${formatDate(date)}`"
></div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
</div>
</div>
</div>
</div>
</div>
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask.id"
@close="closeDetailPanel"
@task-updated="loadTasks"
/>
</DetailPanelOverlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { DatePicker } from '@/components/ui/date-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
projectId: number
}>()
const taskStatusesStore = useTaskStatusesStore()
const { toast } = useToast()
const {
selectedEntity: selectedTask,
showMobileDetail,
showPanel,
closeDetailPanel,
selectEntity: selectTask
} = useDetailPanel<TaskListItem>({ sessionStorageKey: 'scheduleGantt.detailPanelEnabled' })
const isLoading = ref(false)
const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([])
const submissionDates = ref<SubmissionDateInfo[]>([])
const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56
const STATUS_COLUMN_WIDTH = 200 // status select (130px) + assignee avatar button, with padding
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
const HEADER_HEIGHT = 44
const GROUP_ROW_HEIGHT = 32
const TASK_ROW_HEIGHT = 36
// The frozen (Task Type/Task Status) pane and the timeline pane are two
// independently-scrolled elements so the horizontal scrollbar only ever
// spans the timeline. Vertical scroll position is kept in sync between them.
const frozenPaneRef = ref<HTMLElement | null>(null)
const timelinePaneRef = ref<HTMLElement | null>(null)
let syncingScroll = false
function handleFrozenScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
syncingScroll = false
}
function handleTimelineScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
syncingScroll = false
}
const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number]
const viewScale = ref<ViewScale>('week')
const SCALE_PIXELS_PER_DAY: Record<ViewScale, number> = { day: 40, week: 22, month: 6 }
const pixelsPerDay = computed(() => SCALE_PIXELS_PER_DAY[viewScale.value])
const manualRangeStart = ref('')
const manualRangeEnd = ref('')
function parseDate(dateStr: string): Date {
return new Date(`${dateStr}T00:00:00Z`)
}
function toDateString(d: Date): string {
return d.toISOString().slice(0, 10)
}
function addDays(d: Date, days: number): Date {
const copy = new Date(d)
copy.setUTCDate(copy.getUTCDate() + days)
return copy
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86400000)
}
function formatTaskType(taskType: string): string {
return taskType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '?'
return parseDate(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
}
async function loadTasks() {
try {
isLoading.value = true
error.value = null
tasks.value = await taskService.getTasks({ projectId: props.projectId, limit: 1000 })
} catch (err: any) {
console.error('Failed to load schedule tasks:', err)
error.value = err.response?.data?.detail || 'Failed to load schedule'
} finally {
isLoading.value = false
}
}
async function loadSubmissionDates() {
try {
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
} catch (err) {
console.error('Failed to load submission dates:', err)
}
}
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
const submissionDatesByTask = computed(() => {
const map = new Map<number, string[]>()
for (const s of submissionDates.value) {
const day = s.submitted_at.slice(0, 10)
const existing = map.get(s.task_id)
if (existing) {
if (!existing.includes(day)) existing.push(day)
} else {
map.set(s.task_id, [day])
}
}
return map
})
function submissionDatesFor(taskId: number): string[] {
return submissionDatesByTask.value.get(taskId) || []
}
const episodeOptions = computed(() => {
const map = new Map<number, string>()
for (const t of tasks.value) {
if (t.episode_id && t.episode_name && !map.has(t.episode_id)) {
map.set(t.episode_id, t.episode_name)
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
})
function handleEpisodeFilterChange(value: unknown) {
episodeFilter.value = value === 'all' ? null : Number(value)
}
const filteredTasks = computed(() => {
if (episodeFilter.value === null) return tasks.value
return tasks.value.filter(t => t.episode_id === episodeFilter.value)
})
const scheduledTasks = computed(() => filteredTasks.value.filter(t => t.start_date && t.deadline))
const unscheduledCount = computed(() => filteredTasks.value.length - scheduledTasks.value.length)
const autoWindowStart = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let min: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.start_date!)
if (!min || d < min) min = d
}
if (!min) return null
const padded = new Date(min)
padded.setUTCDate(padded.getUTCDate() - 3)
return padded
})
const autoWindowEnd = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let max: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.deadline!)
if (!max || d > max) max = d
}
if (!max) return null
const padded = new Date(max)
padded.setUTCDate(padded.getUTCDate() + 3)
return padded
})
const windowStart = computed<Date | null>(() => manualRangeStart.value ? parseDate(manualRangeStart.value) : autoWindowStart.value)
const windowEnd = computed<Date | null>(() => manualRangeEnd.value ? parseDate(manualRangeEnd.value) : autoWindowEnd.value)
const chartWidth = computed(() => {
if (!windowStart.value || !windowEnd.value) return 0
return Math.max(daysBetween(windowStart.value, windowEnd.value) * pixelsPerDay.value, 400)
})
function dateToLeft(date: Date): number {
if (!windowStart.value) return 0
return daysBetween(windowStart.value, date) * pixelsPerDay.value
}
// One bounded, window-clipped cell per calendar month touching [windowStart, windowEnd].
function computeMonthSegments(labelOptions: Intl.DateTimeFormatOptions): { left: number; width: number; label: string }[] {
if (!windowStart.value || !windowEnd.value) return []
const segments: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), windowStart.value.getUTCMonth(), 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextMonth = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextMonth), rightBound) - left, 2)
segments.push({ left, width, label: cursor.toLocaleDateString('en-US', labelOptions) })
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
}
return segments
}
const axisMarkers = computed<{ left: number; width?: number; label: string }[]>(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
return computeMonthSegments({ month: 'short', timeZone: 'UTC' })
}
const markers: { left: number; label: string }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
markers.push({
left: dateToLeft(cursor),
label: String(cursor.getUTCDate())
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return markers
})
// Coarser grouping row shown above axisMarkers: month spans (day/week scale) or year spans (month scale).
const topAxisMarkers = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
const markers: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), 0, 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextYear = new Date(Date.UTC(cursor.getUTCFullYear() + 1, 0, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextYear), rightBound) - left, 2)
markers.push({ left, width, label: String(cursor.getUTCFullYear()) })
cursor.setUTCFullYear(cursor.getUTCFullYear() + 1)
}
return markers
}
return computeMonthSegments({ month: 'long', year: 'numeric', timeZone: 'UTC' })
})
const weekendColumns = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
const columns: { left: number }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
const day = cursor.getUTCDay()
if (day === 0 || day === 6) {
columns.push({ left: dateToLeft(cursor) })
}
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return columns
})
const todayLeft = computed(() => {
if (!windowStart.value || !windowEnd.value) return null
const now = new Date()
const todayUtc = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
if (todayUtc < windowStart.value || todayUtc > windowEnd.value) return null
return dateToLeft(todayUtc)
})
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
type GroupBy = typeof GROUP_BY_OPTIONS[number]
const groupBy = ref<GroupBy>('taskType')
function groupByLabel(mode: GroupBy): string {
return mode === 'taskType' ? 'Task Type' : 'Shot'
}
function entityName(task: TaskListItem): string {
return task.shot_name || task.asset_name || task.name
}
// The row label shows whichever dimension ISN'T already the group header:
// task type when grouped by shot, and the shot/asset name when grouped by task type.
function taskRowLabel(task: TaskListItem): string {
return groupBy.value === 'taskType' ? entityName(task) : formatTaskType(task.task_type)
}
interface RowGroup {
key: string
label: string
tasks: TaskListItem[]
barLeft: number | null
barWidth: number | null
}
const rowGroups = computed<RowGroup[]>(() => {
const byKey = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) {
const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key)!.push(t)
}
return Array.from(byKey.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
if (windowStart.value) {
let minStart: Date | null = null
let maxEnd: Date | null = null
for (const t of sorted) {
const s = parseDate(t.start_date!)
const e = parseDate(t.deadline!)
if (!minStart || s < minStart) minStart = s
if (!maxEnd || e > maxEnd) maxEnd = e
}
barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
}
const label = groupBy.value === 'taskType' ? formatTaskType(key) : key
return { key, label, tasks: sorted, barLeft, barWidth }
})
})
function isCollapsed(key: string): boolean {
return collapsedGroups.value.has(key)
}
function toggleGroup(key: string) {
const next = new Set(collapsedGroups.value)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedGroups.value = next
}
const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? new Set()
: new Set(rowGroups.value.map(g => g.key))
}
watch(groupBy, () => {
collapsedGroups.value = new Set()
})
function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
}
function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id
}
function handleStatusUpdated(taskId: number, newStatus: string) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.status = newStatus
}
function handleAssignmentUpdated(taskId: number, userId: number | null) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assigned_user_id = userId ?? undefined
}
// --- Drag to reschedule ---
interface DragState {
taskId: number
mode: 'move' | 'resize-start' | 'resize-end'
startX: number
originalStart: Date
originalEnd: Date
currentStart: Date
currentEnd: Date
}
const dragState = ref<DragState | null>(null)
function startDrag(event: MouseEvent, task: TaskListItem, mode: DragState['mode']) {
event.preventDefault()
event.stopPropagation()
const originalStart = parseDate(task.start_date!)
const originalEnd = parseDate(task.deadline!)
dragState.value = {
taskId: task.id,
mode,
startX: event.clientX,
originalStart,
originalEnd,
currentStart: originalStart,
currentEnd: originalEnd
}
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragMove(event: MouseEvent) {
const drag = dragState.value
if (!drag) return
const deltaX = event.clientX - drag.startX
const deltaDays = Math.round(deltaX / pixelsPerDay.value)
let newStart = drag.originalStart
let newEnd = drag.originalEnd
if (drag.mode === 'move') {
newStart = addDays(drag.originalStart, deltaDays)
newEnd = addDays(drag.originalEnd, deltaDays)
} else if (drag.mode === 'resize-start') {
newStart = addDays(drag.originalStart, deltaDays)
if (newStart >= drag.originalEnd) newStart = addDays(drag.originalEnd, -1)
} else if (drag.mode === 'resize-end') {
newEnd = addDays(drag.originalEnd, deltaDays)
if (newEnd <= drag.originalStart) newEnd = addDays(drag.originalStart, 1)
}
dragState.value = { ...drag, currentStart: newStart, currentEnd: newEnd }
}
async function handleDragEnd() {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
const drag = dragState.value
dragState.value = null
if (!drag) return
const changed = daysBetween(drag.originalStart, drag.currentStart) !== 0 || daysBetween(drag.originalEnd, drag.currentEnd) !== 0
if (!changed) {
// No movement - treat as a click on the bar body
if (drag.mode === 'move') openTask(drag.taskId)
return
}
const task = tasks.value.find(t => t.id === drag.taskId)
if (!task) return
const previousStart = task.start_date
const previousDeadline = task.deadline
const newStartStr = toDateString(drag.currentStart)
const newEndStr = toDateString(drag.currentEnd)
task.start_date = newStartStr
task.deadline = newEndStr
try {
await taskService.updateTask(drag.taskId, { start_date: newStartStr, deadline: newEndStr })
} catch (err: any) {
console.error('Failed to reschedule task:', err)
task.start_date = previousStart
task.deadline = previousDeadline
toast({
title: 'Error',
description: err.response?.data?.detail || 'Failed to reschedule task',
variant: 'destructive'
})
}
}
function taskBarStyle(task: TaskListItem) {
const isDragging = dragState.value?.taskId === task.id
const start = isDragging ? dragState.value!.currentStart : parseDate(task.start_date!)
const end = isDragging ? dragState.value!.currentEnd : parseDate(task.deadline!)
const left = dateToLeft(start)
const width = Math.max(dateToLeft(end) - left, 6)
return {
left: `${left}px`,
width: `${width}px`,
backgroundColor: statusColor(task)
}
}
function taskBarTitle(task: TaskListItem): string {
return `${task.name}: ${formatDate(task.start_date)} ${formatDate(task.deadline)}`
}
const legendStatuses = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId) || [])
function openTask(taskId: number) {
const task = tasks.value.find(t => t.id === taskId)
if (task) selectTask(task)
}
onMounted(() => {
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
})
watch(() => props.projectId, () => {
episodeFilter.value = null
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
</script>
<style scoped>
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
</style>
@@ -2,9 +2,9 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Custom Task Types</h3>
<h3 class="text-lg font-semibold">Task Types</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom task types beyond the standard types to adapt the pipeline to your project needs
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
</p>
</div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0">
<template v-if="assetTaskTypeRows.length > 0">
<div
v-for="taskType in assetTaskTypes"
:key="taskType"
v-for="row in assetTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('asset', taskType)"
@click="openEditDialog('asset', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('asset', taskType)"
@click="handleDelete('asset', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0">
<template v-if="shotTaskTypeRows.length > 0">
<div
v-for="taskType in shotTaskTypes"
:key="taskType"
v-for="row in shotTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('shot', taskType)"
@click="openEditDialog('shot', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('shot', taskType)"
@click="handleDelete('shot', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -155,7 +149,25 @@
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
3-50 characters, lowercase alphanumeric with underscores only
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Department (optional)</Label>
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
<SelectTrigger>
<SelectValue placeholder="None (general purpose)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (general purpose)</SelectItem>
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
{{ formatTaskTypeName(department.name) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
</p>
</div>
</div>
@@ -181,6 +193,7 @@
// Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = ''
categoryToDelete = ''
departmentToDelete = ''
deleteError = ''
}
}"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
import { departmentService, type DepartmentInfo } from '@/services/department'
import { useDepartmentsStore } from '@/stores/departments'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State
const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('')
const originalTaskTypeName = ref('')
const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
const dialogCustomDepartments = computed(() => {
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
})
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
const rows = new Map<string, TaskTypeRow>()
for (const t of flatAll) {
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
}
for (const department of departments) {
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
for (const t of department.task_types) {
const existing = rows.get(t)
if (existing) {
existing.department = department.name
existing.isStandard = existing.isStandard || isDeptStandard
} else {
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
}
}
}
return Array.from(rows.values())
}
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value
const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
})
// Methods
const loadTaskTypes = async () => {
try {
isLoading.value = true
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
const [types] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
departmentsStore.fetchProjectDepartments(props.projectId, true)
])
taskTypes.value = types
} catch (error: any) {
console.error('Failed to load task types:', error)
toast({
@@ -298,28 +361,21 @@ const loadTaskTypes = async () => {
}
}
const isStandardAssetType = (taskType: string): boolean => {
return standardAssetTypes.value.includes(taskType)
}
const isStandardShotType = (taskType: string): boolean => {
return standardShotTypes.value.includes(taskType)
}
const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ')
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 3) {
validationError.value = 'Task type name must be at least 3 characters'
if (name.length < minLength) {
validationError.value = `Task type name must be at least ${minLength} characters`
return
}
@@ -333,10 +389,10 @@ const validateTaskTypeName = () => {
return
}
// Check for duplicates (only if adding or changing name)
// Check for duplicates against the merged (flat + department) rows for this category
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
if (existingTypes.includes(name)) {
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists'
return
}
@@ -348,15 +404,17 @@ const validateTaskTypeName = () => {
const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add'
dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit'
dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType
originalTaskTypeName.value = taskType
validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => {
isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
@@ -381,17 +441,30 @@ const handleDialogSave = async () => {
isSaving.value = true
if (dialogMode.value === 'add') {
if (dialogDepartment.value) {
const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
console.log('Add task type response:', response)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
} else {
if (editingDepartment.value) {
const response = await departmentService.renameDepartmentTaskType(
props.projectId,
editingDepartment.value,
originalTaskTypeName.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
@@ -402,8 +475,8 @@ const handleDialogSave = async () => {
category: dialogCategory.value
}
)
console.log('Update task type response:', response)
taskTypes.value = response
}
toast({
title: 'Success',
@@ -427,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
categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = ''
isDeleteDialogOpen.value = true
}
@@ -443,6 +517,7 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
@@ -454,12 +529,17 @@ const confirmDelete = async () => {
return
}
if (departmentToDeleteLocal) {
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
taskTypes.value = response
}
toast({
title: 'Success',
@@ -471,16 +551,20 @@ const confirmDelete = async () => {
// Close dialog and clear values
isDeleteDialogOpen.value = false
taskTypeToDelete.value = ''
categoryToDelete.value = ''
categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
const detail = errorData?.detail
if (errorData?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
if (detail?.task_count) {
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
} else if (typeof detail === 'string') {
deleteError.value = detail
} else {
deleteError.value = errorData?.detail || 'Failed to delete task type'
deleteError.value = 'Failed to delete task type'
}
toast({
@@ -0,0 +1,672 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Departments</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
</p>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Content -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-muted-foreground" />
<h4 class="font-semibold">All Departments</h4>
</div>
<Button size="sm" @click="openAddDialog">
<Plus class="h-4 w-4 mr-2" />
Add Department
</Button>
</div>
<div class="border rounded-lg divide-y">
<template v-if="allDepartments.length > 0">
<div
v-for="department in allDepartments"
:key="department.name"
class="p-3 hover:bg-muted/50 transition-colors space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium capitalize">{{ formatName(department.name) }}</span>
<Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
</div>
<div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
<Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
<Pencil class="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" @click="handleDelete(department.name)">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 pl-1">
<Badge
v-for="taskType in department.task_types"
:key="taskType"
variant="secondary"
class="text-xs font-normal capitalize gap-1"
>
{{ formatName(taskType) }}
<button
v-if="!isStandardDepartment(department.name)"
class="hover:text-destructive"
@click="handleDeleteTaskType(department.name, taskType)"
>
<X class="h-3 w-3" />
</button>
</Badge>
<span v-if="department.task_types.length === 0" class="text-xs text-muted-foreground">
No task types defined
</span>
<Button
v-if="!isStandardDepartment(department.name)"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
@click="openAddTaskTypeDialog(department.name)"
>
<Plus class="h-3 w-3 mr-1" />
Add Task Type
</Button>
</div>
</div>
</template>
<div v-else class="p-4 text-center text-sm text-muted-foreground">
No departments defined
</div>
</div>
</div>
<!-- Add/Edit Department Dialog -->
<Dialog :open="isDialogOpen" @update:open="closeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
: 'Update the department name. This will update all team members and tasks using this department.'
}}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="departmentName">Department Name</Label>
<Input
id="departmentName"
v-model="departmentName"
placeholder="e.g., fx, previz, matchmove"
:class="{ 'border-destructive': validationError }"
@input="validateDepartmentName"
/>
<p v-if="validationError" class="text-sm text-destructive">
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
2-50 characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Type</Label>
<Select v-model="departmentType">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shot">Shot</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Shot departments apply to shot tasks; asset departments apply to asset tasks.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeDialog">
Cancel
</Button>
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Add Task Type Dialog -->
<Dialog :open="isTaskTypeDialogOpen" @update:open="closeTaskTypeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Add Task Type to "{{ formatName(taskTypeDepartment) }}"</DialogTitle>
<DialogDescription>
Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="taskTypeName">Task Type Name</Label>
<Input
id="taskTypeName"
v-model="taskTypeName"
placeholder="e.g., blocking, first_pass"
:class="{ 'border-destructive': taskTypeValidationError }"
@input="validateTaskTypeName"
/>
<p v-if="taskTypeValidationError" class="text-sm text-destructive">
{{ taskTypeValidationError }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeTaskTypeDialog">
Cancel
</Button>
<Button @click="handleAddTaskType" :disabled="!isTaskTypeNameValid || isSavingTaskType">
<div v-if="isSavingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Department Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting) {
departmentToDelete = ''
deleteError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Department</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the department "{{ departmentToDelete }}"?
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
{{ deleteError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDelete"
:disabled="isDeleting"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Delete Task Type Confirmation Dialog -->
<AlertDialog
:open="isDeleteTaskTypeDialogOpen"
@update:open="(open) => {
isDeleteTaskTypeDialogOpen = open
if (!open && !isDeletingTaskType) {
taskTypeToDelete = null
deleteTaskTypeError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the task type "{{ taskTypeToDelete?.taskType }}" from "{{ taskTypeToDelete?.department }}"?
<span v-if="deleteTaskTypeError" class="block mt-2 text-destructive font-medium">
{{ deleteTaskTypeError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDeleteTaskType"
:disabled="isDeletingTaskType"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeletingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useDepartmentsStore } from '@/stores/departments'
import { departmentService, type DepartmentType } from '@/services/department'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
updated: []
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
// State
const isLoading = ref(true)
// Department dialog state
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const departmentName = ref('')
const departmentType = ref<DepartmentType | ''>('')
const originalDepartmentName = ref('')
const validationError = ref('')
const isSaving = ref(false)
// Task type dialog state
const isTaskTypeDialogOpen = ref(false)
const taskTypeDepartment = ref('')
const taskTypeName = ref('')
const taskTypeValidationError = ref('')
const isSavingTaskType = ref(false)
// Delete department dialog state
const isDeleteDialogOpen = ref(false)
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
// Delete task type dialog state
const isDeleteTaskTypeDialogOpen = ref(false)
const taskTypeToDelete = ref<{ department: string; taskType: string } | null>(null)
const deleteTaskTypeError = ref('')
const isDeletingTaskType = ref(false)
// Computed
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
const isDepartmentNameValid = computed(() => {
return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
})
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
})
// Methods
const loadDepartments = async () => {
try {
isLoading.value = true
await departmentsStore.fetchProjectDepartments(props.projectId, true)
} catch (error: any) {
console.error('Failed to load departments:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load departments',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const isStandardDepartment = (department: string): boolean => {
return standardDepartments.value.some(d => d.name === department)
}
const formatName = (name: string): string => {
return name.replace(/_/g, ' ')
}
const validateDepartmentName = () => {
const name = departmentName.value.trim()
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 2) {
validationError.value = 'Department name must be at least 2 characters'
return
}
if (name.length > 50) {
validationError.value = 'Department name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
return
}
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
if (allDepartmentNames.value.includes(name)) {
validationError.value = 'A department with this name already exists'
return
}
}
validationError.value = ''
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
if (name.length === 0) {
taskTypeValidationError.value = ''
return
}
if (name.length < 2 || name.length > 50) {
taskTypeValidationError.value = 'Task type name must be 2-50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
taskTypeValidationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
const department = allDepartments.value.find(d => d.name === taskTypeDepartment.value)
if (department?.task_types.includes(name)) {
taskTypeValidationError.value = 'A task type with this name already exists in this department'
return
}
taskTypeValidationError.value = ''
}
const openAddDialog = () => {
dialogMode.value = 'add'
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (department: string) => {
dialogMode.value = 'edit'
departmentName.value = department
originalDepartmentName.value = department
validationError.value = ''
isDialogOpen.value = true
}
const closeDialog = () => {
isDialogOpen.value = false
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
}
const openAddTaskTypeDialog = (department: string) => {
taskTypeDepartment.value = department
taskTypeName.value = ''
taskTypeValidationError.value = ''
isTaskTypeDialogOpen.value = true
}
const closeTaskTypeDialog = () => {
isTaskTypeDialogOpen.value = false
taskTypeDepartment.value = ''
taskTypeName.value = ''
taskTypeValidationError.value = ''
}
const handleDialogSave = async () => {
validateDepartmentName()
if (!isDepartmentNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await departmentService.addDepartment(props.projectId, {
department: departmentName.value.trim(),
department_type: departmentType.value as DepartmentType
})
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentName.value}" added successfully`
})
} else {
const response = await departmentService.updateDepartment(
props.projectId,
originalDepartmentName.value,
{
old_name: originalDepartmentName.value,
new_name: departmentName.value.trim()
}
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
console.error('Failed to save department:', error)
const errorMessage = error.response?.data?.detail || 'Failed to save department'
validationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
const handleAddTaskType = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSavingTaskType.value = true
const response = await departmentService.addDepartmentTaskType(
props.projectId,
taskTypeDepartment.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
emit('updated')
closeTaskTypeDialog()
} catch (error: any) {
console.error('Failed to add task type:', error)
const errorMessage = error.response?.data?.detail || 'Failed to add task type'
taskTypeValidationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSavingTaskType.value = false
}
}
const handleDelete = (department: string) => {
departmentToDelete.value = department
deleteError.value = ''
isDeleteDialogOpen.value = true
}
const confirmDelete = async () => {
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
deleteError.value = ''
if (!departmentToDeleteLocal) {
deleteError.value = 'Department name is missing. Please try again.'
isDeleting.value = false
return
}
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentToDeleteLocal}" deleted successfully`
})
emit('updated')
isDeleteDialogOpen.value = false
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete department:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
const { task_count, member_count } = errorData.detail
const parts = []
if (member_count) parts.push(`${member_count} team member(s)`)
if (task_count) parts.push(`${task_count} task(s)`)
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
} else {
deleteError.value = errorData?.detail || 'Failed to delete department'
}
toast({
title: 'Error',
description: deleteError.value,
variant: 'destructive'
})
} finally {
isDeleting.value = false
}
}
const handleDeleteTaskType = (department: string, taskType: string) => {
taskTypeToDelete.value = { department, taskType }
deleteTaskTypeError.value = ''
isDeleteTaskTypeDialogOpen.value = true
}
const confirmDeleteTaskType = async () => {
const target = taskTypeToDelete.value
if (!target) return
try {
isDeletingTaskType.value = true
deleteTaskTypeError.value = ''
const response = await departmentService.removeDepartmentTaskType(props.projectId, target.department, target.taskType)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${target.taskType}" deleted successfully`
})
emit('updated')
isDeleteTaskTypeDialogOpen.value = false
taskTypeToDelete.value = null
deleteTaskTypeError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined) {
deleteTaskTypeError.value = `Cannot delete: ${errorData.detail.task_count} task(s) are using this task type`
} else {
deleteTaskTypeError.value = errorData?.detail || 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteTaskTypeError.value,
variant: 'destructive'
})
} finally {
isDeletingTaskType.value = false
}
}
// Lifecycle
onMounted(() => {
loadDepartments()
})
</script>
@@ -100,7 +100,7 @@
</p>
<Button @click="showCreateDialog = true" v-if="canManage">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineEmits<{ clear: [] }>()
</script>
<template>
<Button variant="ghost" size="sm" class="h-8 px-2 lg:px-3" @click="$emit('clear')">
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import type { VisibilityState } from '@tanstack/vue-table'
export interface ColumnOption {
id: string
label: string
/** Optional section label; columns without one are grouped first, unlabeled. */
group?: string
}
interface Props {
columns: ColumnOption[]
modelValue: VisibilityState
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: VisibilityState]
}>()
const isVisible = (columnId: string) => props.modelValue[columnId] !== false
const toggle = (columnId: string, value: boolean) => {
emit('update:modelValue', { ...props.modelValue, [columnId]: value })
}
const hiddenCount = computed(() => props.columns.filter(col => !isVisible(col.id)).length)
const groupedColumns = computed(() => {
const groups = new Map<string | undefined, ColumnOption[]>()
for (const column of props.columns) {
const key = column.group
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(column)
}
return Array.from(groups.entries()).map(([label, columns]) => ({ label, columns }))
})
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<template v-for="group in groupedColumns" :key="group.label ?? '__default__'">
<CommandGroup>
<div v-if="group.label" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ group.label }}
</div>
<CommandSeparator v-if="group.label" />
<CheckableCommandItem
v-for="column in group.columns"
:key="column.id"
:value="column.id"
:model-value="isVisible(column.id)"
@update:model-value="(value) => toggle(column.id, value)"
>
<span>{{ column.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</template>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ title: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">{{ title }}</h3>
<p class="text-muted-foreground mb-4">{{ message }}</p>
<Button @click="$emit('retry')" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
interface Props {
title: string
deletedAt?: string | null
}
defineProps<Props>()
defineEmits<{ close: [] }>()
</script>
<template>
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': deletedAt }">{{ title }}</h2>
<slot name="badges" />
</div>
<Button variant="ghost" size="icon-sm" class="flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<div class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">{{ label }}</span>
</div>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Sheet, SheetContent } from '@/components/ui/sheet'
interface Props {
visible: boolean
mobileOpen: boolean
}
defineProps<Props>()
const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
</script>
<template>
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="visible"
class="fixed right-0 top-16 bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<slot />
</div>
</Transition>
<Sheet :open="mobileOpen" @update:open="emit('update:mobileOpen', $event)">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<slot />
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ enabled: boolean }>()
defineEmits<{ toggle: [] }>()
</script>
<template>
<Button
@click="$emit('toggle')"
:variant="enabled ? 'default' : 'outline'"
size="icon-sm"
:class="enabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="enabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="enabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col h-full">
<!-- Filter / Sort Toolbar -->
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-1.5">
<Popover v-if="tasks.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
Tasks
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
{{ taskFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-52 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="taskFilters.length === 0"
@update:model-value="taskFilters = []"
>
All Tasks
</CheckableCommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CheckableCommandItem
v-for="task in tasks"
:key="task.id"
:value="String(task.id)"
:model-value="taskFilters.includes(task.id)"
@update:model-value="toggleTaskFilter(task.id)"
>
{{ formatTaskType(task.task_type) }}
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
size="icon-sm"
@click="showSubmissionNotes = !showSubmissionNotes"
>
<Send class="h-3.5 w-3.5" />
<span class="sr-only">Submission notes</span>
</Button>
</TooltipTrigger>
<TooltipContent>Submission notes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showClientOnly ? 'secondary' : 'outline'"
size="icon-sm"
@click="showClientOnly = !showClientOnly"
>
<Megaphone class="h-3.5 w-3.5" />
<span class="sr-only">Client notes only</span>
</Button>
</TooltipTrigger>
<TooltipContent>Client notes only</TooltipContent>
</Tooltip>
</div>
<Select v-model="sortOrder">
<SelectTrigger class="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="combinedEntries.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet. Start the conversation below.</p>
</div>
<template v-for="entry in combinedEntries" :key="entry.id">
<!-- Production Note -->
<div v-if="entry.kind === 'note'" class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div>
<NoteItem
:note="entry.note"
:task-id="entry.note.task_id"
date-format="absolute"
hide-client-badge
@note-updated="emit('notesUpdated')"
@reply="handleReply"
/>
</div>
<!-- Submission Note (read-only) -->
<div v-else class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
</div>
<div class="flex gap-3">
<Avatar class="h-8 w-8 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
</span>
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateOnly(entry.submission.submitted_at) }}
<Tooltip>
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
</div>
</div>
</div>
</template>
</div>
<!-- Note Input (Bottom) -->
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select task..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
type="button"
size="sm"
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
@click="newNoteType = 'internal'"
>
Internal
</Button>
<Button
type="button"
size="sm"
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
@click="newNoteType = 'client'"
>
Client
</Button>
</div>
<Button
size="sm"
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
@click="handleAddNote"
>
<MessageSquarePlus class="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import NoteItem from '@/components/task/NoteItem.vue'
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface EntityNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: EntityNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
const { toast } = useToast()
const { isCoordinatorOrAdmin } = usePermission()
const { getAvatarUrl } = useAvatarUrl()
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const taskFilters = ref<number[]>([])
const sortOrder = ref<'newest' | 'oldest'>('newest')
const showSubmissionNotes = ref(true)
const showClientOnly = ref(false)
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const targetTaskId = ref<number | null>(null)
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
watch(() => props.tasks, (tasks) => {
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
targetTaskId.value = tasks[0]?.id ?? null
}
}, { immediate: true })
function toggleTaskFilter(taskId: number) {
const index = taskFilters.value.indexOf(taskId)
if (index > -1) taskFilters.value.splice(index, 1)
else taskFilters.value.push(taskId)
}
function isFilterActive(taskId: number): boolean {
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
}
function taskTypeFor(taskId: number): string {
return props.tasks.find(t => t.id === taskId)?.task_type || ''
}
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function formatDateOnly(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
interface NoteEntry {
kind: 'note'
id: string
date: string
note: ProductionNote
}
interface SubmissionEntry {
kind: 'submission'
id: string
date: string
submission: Submission
}
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
const noteEntries: NoteEntry[] = props.notes
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
? props.submissions
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
: []
const direction = sortOrder.value === 'newest' ? -1 : 1
return [...noteEntries, ...submissionEntries].sort(
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
)
})
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
function handleReply(noteId: number) {
const note = findNote(props.notes, noteId)
if (!note) return
replyToNoteId.value = noteId
targetTaskId.value = note.task_id
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
async function handleAddNote() {
if (!newNoteContent.value.trim() || !targetTaskId.value) return
submitting.value = true
try {
await taskService.createTaskNote(
targetTaskId.value,
newNoteContent.value,
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
title: 'Success',
description: 'Note added successfully'
})
} catch (error: any) {
console.error('Error adding note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to add note',
variant: 'destructive'
})
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
export interface SegmentOption {
value: string
label?: string
icon?: unknown
title?: string
}
interface Props {
options: SegmentOption[]
modelValue: string
}
defineProps<Props>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
v-for="option in options"
:key="option.value"
variant="ghost"
size="sm"
:class="{ 'bg-muted': modelValue === option.value }"
:title="option.title"
class="h-7 px-2"
@click="$emit('update:modelValue', option.value)"
>
<component :is="option.icon" v-if="option.icon" class="h-4 w-4" />
<template v-if="option.label">{{ option.label }}</template>
</Button>
</div>
</template>
@@ -1,77 +1,6 @@
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CommandItem value="all" @select="clearAllFilters">
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.length === 0
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Tasks</span>
</CommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
@select="toggleFilter(`${taskType}:${status.id}`)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.includes(`${taskType}:${status.id}`)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter, Check } from 'lucide-vue-next'
import { ListFilter } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -84,12 +13,11 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
@@ -106,12 +34,13 @@ const emit = defineEmits<{
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// System status options (fallback if no project ID / statuses not loaded yet)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true },
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
{ id: 'approved', name: 'Approved', color: '', is_system: true },
{ id: 'retake', name: 'Retake', color: '', is_system: true },
]
const allStatuses = computed(() => {
@@ -160,3 +89,59 @@ const clearAllFilters = () => {
const formatTaskType = (taskType: string) =>
taskType.charAt(0).toUpperCase() + taskType.slice(1)
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="selectedFilters.length === 0"
@update:model-value="clearAllFilters"
>
<span>All Tasks</span>
</CheckableCommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CheckableCommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
:model-value="selectedFilters.includes(`${taskType}:${status.id}`)"
@update:model-value="toggleFilter(`${taskType}:${status.id}`)"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
interface StatusOption {
id: string
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
@@ -306,27 +308,18 @@ const fetchStatuses = async () => {
}
}
// Load project members
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
console.log('Loading project members for project:', props.projectId)
projectMembers.value = await projectService.getProjectMembers(props.projectId)
console.log('Loaded project members:', projectMembers.value)
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
console.log('Ensuring project members are loaded')
if (projectMembers.value.length === 0) {
console.log('Loading project members on button click')
loadProjectMembers()
}
}
@@ -407,7 +400,6 @@ onMounted(() => {
// Refetch statuses when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
loadProjectMembers()
})
</script>
+34 -47
View File
@@ -120,7 +120,7 @@
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -160,50 +160,17 @@
</div>
</div>
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
<!-- Detail Panel (Overlay - Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedShot)"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel
v-else
:project-id="projectId"
:shot-id="selectedShot.id"
:initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot"
@delete="deleteShot"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
</div>
</Transition>
</div>
<!-- Mobile Detail Sheet -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
:initial-reply-note-id="selectedTaskReplyNoteId"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
@@ -216,10 +183,10 @@
@edit="editShot"
@delete="deleteShot"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
</SheetContent>
</Sheet>
</DetailPanelOverlay>
</div>
<!-- Dialogs -->
<div class="space-y-4">
@@ -317,10 +284,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Sheet,
SheetContent,
} from '@/components/ui/sheet'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
import {
DropdownMenu,
@@ -492,6 +456,11 @@ const loadEpisodes = async () => {
episodes.value = data
} catch (err) {
console.error('Failed to load episodes:', err)
toast({
title: 'Failed to load episodes',
description: err instanceof Error ? err.message : 'Episode filtering may be unavailable',
variant: 'destructive'
})
}
}
@@ -503,6 +472,11 @@ const loadTaskTypes = async () => {
allTaskTypes.value = data.shot_task_types || []
} catch (err) {
console.error('Failed to load task types:', err)
toast({
title: 'Failed to load task types',
description: err instanceof Error ? err.message : 'Task columns may be unavailable',
variant: 'destructive'
})
}
}
@@ -513,6 +487,11 @@ const loadTaskStatuses = async () => {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (err) {
console.error('Failed to load task statuses:', err)
toast({
title: 'Failed to load task statuses',
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
variant: 'destructive'
})
}
}
@@ -524,6 +503,11 @@ const loadProjectContext = async () => {
projectContext.value = project
} catch (err) {
console.error('Failed to load project context:', err)
toast({
title: 'Failed to load project context',
description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable',
variant: 'destructive'
})
}
}
@@ -865,16 +849,19 @@ const clearSearch = () => {
const selectedTaskId = ref<number | null>(null)
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
selectedTaskTab.value = tab || 'infos'
selectedTaskReplyNoteId.value = noteId
}
// Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => {
selectedTaskId.value = null
selectedTaskTab.value = 'infos'
selectedTaskReplyNoteId.value = undefined
})
const formatStatus = (status: ShotStatus) => {
+1 -1
View File
@@ -21,7 +21,7 @@
<!-- Actions Menu -->
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon-sm" class="opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal class="h-4 w-4" />
<span class="sr-only">Shot actions</span>
</Button>
+163 -186
View File
@@ -1,63 +1,60 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading shot details...</span>
</div>
</div>
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load shot</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadShotDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<DetailPanelError
v-else-if="error"
title="Failed to load shot"
:message="error"
@retry="loadShotDetails"
/>
<!-- Shot Details -->
<div v-else-if="shot" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">{{ shot.name }}</h2>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<div v-else-if="shot" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="assets">Assets</TabsTrigger>
<TabsTrigger value="references">References</TabsTrigger>
<TabsTrigger value="design">Design</TabsTrigger>
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="shotNotes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="assets" title="Assets">
<Package class="h-4 w-4" />
<span class="sr-only">Assets</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
</TabsTrigger>
<TabsTrigger value="design" title="Design">
<Edit class="h-4 w-4" />
<span class="sr-only">Design</span>
</TabsTrigger>
</TabsList>
<!-- Infos Tab -->
<TabsContent value="infos" class="flex-1 p-6 space-y-6">
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
<!-- Shot Information -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Shot Information</h3>
@@ -162,7 +159,7 @@
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Button variant="ghost" size="icon-sm" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -190,71 +187,45 @@
<p class="text-sm text-muted-foreground">No tasks yet</p>
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
</div>
<!-- Tasks Table -->
<div v-else class="border rounded-lg overflow-hidden">
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
<div>Task Type</div>
<div>Assignee</div>
<div>Status</div>
</div>
<div
<!-- Tasks Cards -->
<div v-else class="space-y-1.5">
<Card
v-for="task in tasks"
:key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
@click="$emit('select-task', task, 'infos')"
>
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-sm text-muted-foreground">
{{ task.assigned_user_name || 'Unassigned' }}
</div>
<div>
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
{{ formatTaskStatus(task.status.toString()) }}
</Badge>
</div>
<span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
<div class="flex items-center gap-2 flex-shrink-0">
<Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
</Avatar>
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
</div>
</Card>
</div>
</div>
</TabsContent>
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Production Notes</h3>
<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>
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes...
</div>
<EntityNotes
v-else
:key="shotId"
:tasks="tasks"
:notes="shotNotes"
:submissions="shotSubmissions"
@notes-updated="loadShotNotes"
/>
</TabsContent>
<!-- Assets Tab -->
<TabsContent value="assets" class="flex-1 p-6">
<TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3>
<Button
@@ -276,7 +247,7 @@
</TabsContent>
<!-- References Tab -->
<TabsContent value="references" class="flex-1 p-6">
<TabsContent value="references" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3>
<Popover v-if="canUploadReferences">
@@ -312,7 +283,7 @@
</TabsContent>
<!-- Design Tab -->
<TabsContent value="design" class="flex-1 p-6">
<TabsContent value="design" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3>
<Button
@@ -351,23 +322,37 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
// Use TaskStatusInfo from shot service instead of local Task interface
interface Task extends TaskStatusInfo {
id: number
name?: string
assigned_user_name?: string
assigned_user_avatar_url?: string | null
assigned_user_first_name?: string
assigned_user_last_name?: string
deadline?: string
}
@@ -381,7 +366,7 @@ interface Props {
interface Emits {
(e: 'edit', 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: 'edit-design'): void
(e: 'close'): void
@@ -390,7 +375,10 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
// Reactive state
const shot = ref<Shot | null>(null)
@@ -398,6 +386,10 @@ const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const shotSubmissions = ref<Submission[]>([])
const isLoadingNotes = ref(false)
// Computed properties
const frameCount = computed(() => {
@@ -435,29 +427,25 @@ const taskStatusCounts = computed(() => {
const canCreateTask = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => {
return true // All users can upload references
})
const canEditDesign = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
// Task types offered in "Add Task" include the flat shot task type list
// plus every task type owned by a shot department (e.g. Animation's own
// task types), deduped against types the shot already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
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
@@ -466,7 +454,13 @@ const loadShotDetails = async () => {
isLoading.value = true
error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers()
])
loadTasks() // No longer async - uses embedded data
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err)
@@ -475,23 +469,64 @@ const loadShotDetails = async () => {
}
}
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
shotSubmissions.value = []
return
}
try {
isLoadingNotes.value = true
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
shotNotes.value = notesByTask.flat()
shotSubmissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
const loadProjectMembers = async () => {
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
} catch (err) {
console.error('Failed to load project members:', err)
}
}
const loadTasks = () => {
// Use task_details already embedded in shot data - no API call needed!
if (shot.value?.task_details) {
tasks.value = shot.value.task_details.map(taskInfo => ({
tasks.value = shot.value.task_details.map(taskInfo => {
const assignedUser = projectMembers.value.find(member => member.user_id === taskInfo.assigned_user_id)
return {
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// Add placeholder values for display compatibility
name: taskInfo.task_type, // Use task_type as name for display
assigned_user_name: undefined // Will be resolved if needed
}))
assigned_user_name: assignedUser ? `${assignedUser.user_first_name} ${assignedUser.user_last_name}` : undefined,
assigned_user_avatar_url: assignedUser?.user_avatar_url,
assigned_user_first_name: assignedUser?.user_first_name,
assigned_user_last_name: assignedUser?.user_last_name
}
})
} else {
tasks.value = []
}
}
// Resolve the project's actual status object (name + color) for a task, matching the shot table
const getTaskStatusObject = (task: Task) => {
const statusId = task.status.toString()
return taskStatusesStore.getStatusById(props.projectId, statusId) || statusId
}
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
@@ -506,74 +541,16 @@ const handleAddTask = async (taskType: string) => {
}
}
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskStatus = (status: string) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const getTaskStatusVariant = (status: string | TaskStatus) => {
const statusStr = status.toString()
switch (statusStr) {
case 'not_started':
return 'secondary'
case 'in_progress':
return 'default'
case 'submitted':
return 'outline'
case 'approved':
return 'default'
case 'retake':
return 'destructive'
default:
return 'secondary'
}
const getTaskAssigneeInitials = (task: Task) => {
const first = task.assigned_user_first_name?.charAt(0) || ''
const last = task.assigned_user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const formatDate = (dateString: string) => {
+52 -176
View File
@@ -5,35 +5,11 @@
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'table' }"
@click="$emit('update:view-mode', 'table')"
class="h-7 px-2"
>
<Table2 class="h-4 w-4" />
</Button>
</div>
<SegmentedToggle
:options="viewModeOptions"
:model-value="viewMode"
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list' | 'table')"
/>
<!-- Episode Filter -->
<Popover v-if="episodes.length > 0">
@@ -56,40 +32,22 @@
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:episode-filter', null)"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -97,7 +55,7 @@
</Popover>
<!-- Task Status Filter (only for table view) -->
<ShotTaskStatusFilter
<TaskStatusFilter
v-if="viewMode === 'table'"
:all-task-types="allTaskTypes"
:project-id="projectId"
@@ -105,79 +63,12 @@
/>
<!-- Column Visibility Control (only for table view) -->
<Popover v-if="viewMode === 'table'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
All Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ColumnToggleList
v-if="viewMode === 'table'"
:columns="columnToggleOptions"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
<!-- Task Columns Toggle Button (only for table view) -->
<Button
@@ -193,28 +84,19 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
<Button
<DetailPanelToggleButton
v-if="viewMode === 'table'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
@@ -222,16 +104,7 @@
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -248,10 +121,10 @@
</div>
<!-- Action Buttons -->
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('bulk-create')" variant="outline" size="icon-sm">
<Layers class="h-4 w-4" />
</Button>
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-shot')" size="icon-sm" title="Create Shot">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -263,7 +136,7 @@
import { computed } from 'vue'
import {
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX, Lock, Unlock
ListTodo, ListX, Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -278,11 +151,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
import type { Shot } from '@/services/shot'
@@ -338,9 +215,20 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'All Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
{ value: 'table', icon: Table2 },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -349,21 +237,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
@@ -1,204 +0,0 @@
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CommandItem
value="all"
@select="clearAllFilters"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.length === 0
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Tasks</span>
</CommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup
v-for="taskType in allTaskTypes"
:key="taskType"
>
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
@select="toggleFilter(`${taskType}:${status.id}`)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.includes(`${taskType}:${status.id}`)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter, Check } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
allTaskTypes: string[]
projectId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'filter-changed': [filter: string]
}>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// Get loading state from store
const isLoading = computed(() => {
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
})
// System status options (fallback if no project ID)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
]
// Combine system and custom statuses
const allStatuses = computed(() => {
if (!props.projectId) {
return defaultStatusOptions
}
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) {
return defaultStatusOptions
}
// Convert system statuses to the format expected by TaskStatusBadge
const systemStatusList = statusData.system_statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: status.is_system
}))
// Convert custom statuses to the format expected by TaskStatusBadge
const customStatusList = statusData.statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: false
}))
return [...systemStatusList, ...customStatusList]
})
// Load custom statuses when component mounts or projectId changes
const loadStatuses = async () => {
if (!props.projectId) {
return
}
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to load task statuses:', error)
}
}
onMounted(() => {
loadStatuses()
})
watch(() => props.projectId, () => {
loadStatuses()
})
const toggleFilter = (filter: string) => {
const index = selectedFilters.value.indexOf(filter)
if (index > -1) {
selectedFilters.value.splice(index, 1)
} else {
selectedFilters.value.push(filter)
}
// Emit the filters as a comma-separated string, or empty string if none selected
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
emit('filter-changed', apiFilter)
}
const clearAllFilters = () => {
selectedFilters.value = []
emit('filter-changed', '')
}
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
}
</script>
@@ -7,7 +7,7 @@
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
@@ -51,7 +51,7 @@
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
@@ -102,7 +102,7 @@
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -144,7 +144,7 @@
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
+1 -2
View File
@@ -277,8 +277,7 @@ export const createShotColumns = (
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -1,5 +1,5 @@
<template>
<div class="relative"
<div class="relative flex items-center gap-1"
>
<Select
@@ -17,7 +17,7 @@
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent @contextmenu.prevent>
<!-- System Statuses -->
<SelectItem
v-for="statusOption in allStatusOptions"
@@ -37,6 +37,112 @@
</SelectContent>
</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 -->
<div
v-if="isUpdating || isLoadingStatuses"
@@ -56,11 +162,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import type { ProjectMember } from '@/services/project'
interface StatusOption {
id: string
@@ -73,10 +191,13 @@ interface Props {
taskId: number
status: TaskStatus | string
projectId: number
showAssignee?: boolean
assignedUserId?: number | null
}
interface Emits {
(e: 'status-updated', taskId: number, newStatus: string): void
(e: 'assignment-updated', taskId: number, userId: number | null): void
}
const props = defineProps<Props>()
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const { getAvatarUrl } = useAvatarUrl()
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
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
+55 -20
View File
@@ -19,39 +19,50 @@
</div>
<!-- Content -->
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0 space-y-1">
<!-- Message Card -->
<div
class="rounded-2xl border px-3 py-2"
:class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<span class="text-xs text-muted-foreground">
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateTime(note.created_at) }}
</span>
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
(edited)
<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 -->
<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 }}
</div>
<!-- Edit Form -->
<div v-else class="space-y-2">
<div v-else class="space-y-2 mt-1">
<Textarea
v-model="editContent"
rows="3"
class="resize-none"
class="resize-none bg-background"
/>
<div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div>
</div>
<!-- Actions -->
<div v-if="!editing" class="flex gap-2">
<div v-if="!editing" class="flex gap-2 px-1">
<Button
variant="ghost"
size="sm"
@@ -63,30 +74,32 @@
<Button
v-if="canEdit"
variant="ghost"
size="sm"
size="icon-sm"
title="Edit"
@click="startEdit"
>
<Pencil class="h-3 w-3 mr-1" />
Edit
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
v-if="canDelete"
variant="ghost"
size="sm"
size="icon-sm"
title="Delete"
@click="handleDelete"
>
<Trash2 class="h-3 w-3 mr-1" />
Delete
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<!-- 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
v-for="childNote in note.child_notes"
:key="childNote.id"
:note="childNote"
:task-id="taskId"
:date-format="dateFormat"
:hide-client-badge="hideClientBadge"
@note-updated="emit('noteUpdated')"
@reply="emit('reply', $event)"
/>
@@ -115,10 +128,12 @@
<script setup lang="ts">
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 { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
AlertDialog,
AlertDialogAction,
@@ -131,11 +146,14 @@ import {
} from '@/components/ui/alert-dialog'
import { taskService, type ProductionNote } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
note: ProductionNote
taskId: number
dateFormat?: 'relative' | 'absolute'
hideClientBadge?: boolean
}>()
const emit = defineEmits<{
@@ -145,25 +163,42 @@ const emit = defineEmits<{
const { toast } = useToast()
const authStore = useAuthStore()
const { hasPermission } = usePermission()
const editing = ref(false)
const editContent = ref('')
const showDeleteDialog = ref(false)
const isOwnNote = computed(() => authStore.user?.id === props.note.user_id)
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(() => {
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 {
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 {
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 diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
+103 -4
View File
@@ -56,11 +56,19 @@
</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="line-clamp-2">{{ submission.notes }}</p>
</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)">
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
@@ -69,6 +77,7 @@
</p>
</div>
<div v-if="!editing" class="flex gap-2">
<Button
variant="outline"
size="sm"
@@ -77,29 +86,119 @@
<Eye class="h-4 w-4 mr-2" />
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>
<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>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next'
import { ref, computed, onMounted, watch } from 'vue'
import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
import { Card } from '@/components/ui/card'
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 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 { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
submission: Submission
}>()
const emit = defineEmits<{
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)
function getFileExtension(filename: string): string {
+32 -33
View File
@@ -1,9 +1,9 @@
<template>
<div class="relative h-full">
<div class="relative h-full flex flex-col">
<!-- Main Content -->
<div class="space-y-4">
<!-- Toolbar - Sticky -->
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<TaskTableToolbar
v-model:status-filter="statusFilter"
v-model:type-filter="typeFilter"
@@ -24,7 +24,7 @@
</div>
<!-- Task Count / Selection Count -->
<div class="flex items-center justify-between px-4 sm:px-6">
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0" class="font-medium text-foreground">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
@@ -39,6 +39,7 @@
</div>
<!-- Data Table -->
<div class="flex-1 min-h-0">
<TasksDataTable
:tasks="filteredTasks"
:column-visibility="columnVisibility"
@@ -53,33 +54,13 @@
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
/>
</div>
<!-- Task Detail Panel - Desktop (Fixed Right Side) -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
:is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</div>
</Transition>
<!-- Task Detail Panel - Mobile (Sheet) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<!-- Task Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!showPanel"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
@@ -87,8 +68,7 @@
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</SheetContent>
</Sheet>
</DetailPanelOverlay>
<!-- Context Menu for Bulk Actions -->
<TaskBulkActionsMenu
@@ -106,7 +86,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { VisibilityState } from '@tanstack/vue-table'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import TaskTableToolbar from './TaskTableToolbar.vue'
import TaskDetailPanel from './TaskDetailPanel.vue'
@@ -120,6 +100,7 @@ import { projectService, type ProjectMember } from '@/services/project'
import { shotService, type Shot } from '@/services/shot'
import { assetService, type Asset } from '@/services/asset'
import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
interface Props {
@@ -130,6 +111,7 @@ const props = defineProps<Props>()
const { toast } = useToast()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
// Detail panel composable
const {
@@ -482,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
onMounted(() => {
loadColumnVisibility()
fetchTasks()
fetchEpisodes()
fetchProjectMembers()
loadTaskStatuses()
})
onUnmounted(() => {
@@ -502,6 +500,7 @@ watch(
fetchTasks()
fetchEpisodes()
fetchProjectMembers()
loadTaskStatuses()
}
)
@@ -1,5 +1,9 @@
<template>
<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
:style="{
position: 'fixed',
@@ -7,6 +11,7 @@
top: `${props.position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}"
/>
<PopoverContent
@@ -15,6 +20,10 @@
:align="'start'"
@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 -->
<div class="px-2 py-1.5 text-sm font-semibold text-muted-foreground border-b mb-1">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
@@ -37,6 +46,7 @@
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48" side="right" align="start">
<div @contextmenu.prevent>
<!-- Loading state -->
<div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground">
Loading statuses...
@@ -52,13 +62,8 @@
: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>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
<!-- Divider if both system and custom statuses exist -->
@@ -73,40 +78,94 @@
: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>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
</template>
</div>
</DropdownMenuContent>
</DropdownMenu>
<!-- Divider -->
<div class="h-px bg-border my-1" />
<!-- Assign To section -->
<div class="py-1">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Assign To
<!-- Assign To submenu -->
<DropdownMenu @update:open="onAssignMenuOpenChange">
<DropdownMenuTrigger as-child>
<button
:disabled="isProcessing || hasMultipleProjects"
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between"
>
<span>Assign To</span>
<ChevronRight class="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56 p-0" side="right" align="start">
<div @contextmenu.prevent>
<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 class="max-h-48 overflow-y-auto">
<button
v-for="member in projectMembers"
:key="member.user_id"
:disabled="isProcessing || hasMultipleProjects"
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
@click="handleAssigneeSelected(member.user_id)"
>
{{ member.user_first_name }} {{ member.user_last_name }}
</button>
<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>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PopoverContent>
</Popover>
@@ -125,11 +184,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} 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 { Task } from '@/services/task'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface Props {
open: boolean
@@ -154,10 +217,14 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const { getAvatarUrl } = useAvatarUrl()
// Local state for menu open/close
const isOpen = ref(props.open)
// Assign To search state
const assigneeSearchQuery = ref('')
// Computed properties
const hasMultipleProjects = computed(() => {
if (props.selectedTasks.length === 0) return false
@@ -188,6 +255,54 @@ const customStatuses = computed(() => {
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
const fetchStatuses = async () => {
if (!currentProjectId.value || hasMultipleProjects.value) {
+244 -63
View File
@@ -1,50 +1,50 @@
<template>
<div class="flex flex-col h-full">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading task details...</span>
</div>
</div>
<DetailPanelLoading v-if="loading" label="Loading task details..." />
<DetailPanelError
v-else-if="error"
title="Failed to load task"
:message="error"
@retry="loadTask"
/>
<!-- Task Details -->
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
<!-- Header (Fixed) -->
<div class="flex-shrink-0 p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate">{{ task.name }}</h2>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
<!-- Tabs List (Fixed) -->
<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="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="attachments">
Attachments
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="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 }}
</Badge>
</TabsTrigger>
<TabsTrigger value="submissions">
Submissions
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="submissions" title="Submissions">
<Upload class="h-4 w-4" />
<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 }}
</Badge>
</TabsTrigger>
@@ -119,16 +119,54 @@
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<Label class="text-muted-foreground">Type</Label>
<p class="text-sm mt-1">
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
</p>
<div class="mt-1">
<Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
<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>
<Label class="text-muted-foreground">Deadline</Label>
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)">
<Calendar class="h-3 w-3" />
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }}
</p>
<div class="mt-1">
<DatePicker
v-model="localDeadline"
placeholder="Set deadline"
@update:model-value="(val) => handleDateChange('deadline', val || '')"
/>
</div>
</div>
</div>
<div>
@@ -186,7 +224,12 @@
<!-- Notes Tab -->
<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>
<!-- Attachments Tab -->
@@ -196,7 +239,16 @@
<!-- Submissions Tab -->
<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>
</Tabs>
</div>
@@ -270,11 +322,15 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { X, 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 { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { DatePicker } from '@/components/ui/date-picker'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import {
Select,
SelectContent,
@@ -304,18 +360,20 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import TaskStatusBadge from './TaskStatusBadge.vue'
import TaskNotes from './TaskNotes.vue'
import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue'
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import { useDepartmentsStore } from '@/stores/departments'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
initialTab?: string
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
@@ -325,10 +383,18 @@ const emit = defineEmits<{
const { toast } = useToast()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const departmentsStore = useDepartmentsStore()
const task = ref<Task | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
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 attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([])
@@ -354,21 +420,56 @@ const canSubmitWork = computed(() => {
)
})
const canReassign = computed(() => {
if (!authStore.user) return false
return authStore.user.is_admin || authStore.user.role === 'coordinator'
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() {
loading.value = true
error.value = null
try {
task.value = await taskService.getTask(props.taskId)
localStatus.value = task.value.status
} catch (error: any) {
console.error('Error loading task:', error)
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) {
console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task'
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load task',
description: error.value,
variant: 'destructive'
})
} finally {
@@ -422,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') {
if (!task.value) return
@@ -480,6 +673,7 @@ function getUserInitials(member: ProjectMember): string {
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
const { getAvatarUrl } = useAvatarUrl()
@@ -500,19 +694,6 @@ function formatDate(dateString: string): string {
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, () => {
loadTask()
loadNotes()
+3 -5
View File
@@ -299,7 +299,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -344,7 +344,7 @@ const emit = defineEmits<{
}>()
const tasksStore = useTasksStore()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { toast } = useToast()
const searchQuery = ref('')
@@ -361,9 +361,7 @@ const assignmentDepartmentFilter = ref('all')
const projectMembers = ref<ProjectMember[]>([])
const assigningTask = ref(false)
const canAssignTasks = computed(() => {
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
})
const canAssignTasks = computed(() => isCoordinatorOrAdmin.value)
const showDepartmentFilter = computed(() => {
return canAssignTasks.value
+68 -6
View File
@@ -20,13 +20,42 @@
<!-- Note Input (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
<div class="flex justify-end">
</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
@click="handleAddNote"
:disabled="!newNoteContent.trim() || submitting"
@@ -42,17 +71,18 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { MessageSquarePlus } from 'lucide-vue-next'
import { ref, computed, nextTick, onMounted } from 'vue'
import { MessageSquarePlus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
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'
const props = defineProps<{
taskId: number
notes: ProductionNote[]
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
@@ -62,8 +92,26 @@ const emit = defineEmits<{
const { toast } = useToast()
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const submitting = ref(false)
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() {
if (!newNoteContent.value.trim()) return
@@ -73,9 +121,11 @@ async function handleAddNote() {
await taskService.createTaskNote(
props.taskId,
newNoteContent.value,
replyToNoteId.value || undefined
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
@@ -96,6 +146,18 @@ async function handleAddNote() {
function handleReply(noteId: number) {
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>
@@ -11,8 +11,10 @@
<SubmissionCard
v-for="submission in submissions"
:key="submission.id"
:task-id="taskId"
:submission="submission"
@view="handleView"
@submission-updated="emit('submissionsUpdated')"
/>
</div>
</div>
@@ -20,6 +22,26 @@
<!-- Submit Work Form (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-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">
<input
ref="fileInput"
@@ -108,7 +130,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { Upload } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
@@ -122,12 +144,20 @@ import {
} from '@/components/ui/dialog'
import SubmissionCard from './SubmissionCard.vue'
import { taskService, type Submission } from '@/services/task'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { useToast } from '@/components/ui/toast/use-toast'
import { apiClient } from '@/services/api'
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
const props = defineProps<{
taskId: number
submissions: Submission[]
taskType?: string
projectId?: number
name?: string
taskName?: string
projectName?: string
}>()
const emit = defineEmits<{
@@ -142,12 +172,213 @@ const submissionNotes = ref('')
const viewerOpen = ref(false)
const selectedSubmission = ref<Submission | 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) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
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
try {
await taskService.submitWork(
+105 -180
View File
@@ -15,35 +15,11 @@
</Button>
<!-- Context Filter Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'all'}"
@click="$emit('update:context-filter', 'all')"
class="h-7 px-2"
>
All
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'shots'}"
@click="$emit('update:context-filter', 'shots')"
class="h-7 px-2"
>
<Film class="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted': contextFilter === 'assets'}"
@click="$emit('update:context-filter', 'assets')"
class="h-7 px-2"
>
<Package class="h-4 w-4 mr-1" />
</Button>
</div>
<SegmentedToggle
:options="contextFilterOptions"
:model-value="contextFilter"
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
/>
<!-- Status Filter -->
<Popover>
<PopoverTrigger as-child>
@@ -65,24 +41,15 @@
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
@select="toggleStatusFilter(status.value)"
:model-value="statusFilter.includes(status.value)"
@update:model-value="toggleStatusFilter(status.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
statusFilter.includes(status.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ status.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -110,24 +77,58 @@
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="type in taskTypes"
:key="type"
:value="type"
@select="toggleTypeFilter(type)"
:model-value="typeFilter.includes(type)"
@update:model-value="toggleTypeFilter(type)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
typeFilter.includes(type)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Project Filter (only shown when a cross-project task list is passed in, e.g. My Tasks) -->
<Popover v-if="projects && projects.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<FolderOpen class="mr-2 h-4 w-4" />
Project
<Badge
v-if="projectFilter !== null"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
1
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search project..." />
<CommandList>
<CommandEmpty>No project found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="projectFilter === null"
@update:model-value="$emit('update:project-filter', null)"
>
<span>All Projects</span>
</CheckableCommandItem>
<CheckableCommandItem
v-for="project in projects"
:key="project.id"
:value="project.id.toString()"
:model-value="projectFilter === project.id"
@update:model-value="$emit('update:project-filter', project.id)"
>
<span>{{ project.name }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -155,40 +156,22 @@
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:episode-filter', null)"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -216,24 +199,15 @@
<CommandList>
<CommandEmpty>No assignee found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="assignee in assignees"
:key="assignee.id"
:value="assignee.id.toString()"
@select="toggleAssigneeFilter(assignee.id)"
:model-value="assigneeFilter.includes(assignee.id)"
@update:model-value="toggleAssigneeFilter(assignee.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
assigneeFilter.includes(assignee.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ assignee.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -241,64 +215,19 @@
</Popover>
<!-- Column Visibility -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="column in allColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
<div class="ml-auto">
<ColumnToggleList
:columns="allColumns"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
</div>
<span>{{ column.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Detail Panel Enable/Disable Toggle Button -->
<Button
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
<DetailPanelToggleButton
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Search -->
<div class="relative flex-1">
@@ -312,23 +241,14 @@
</div>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, FolderOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@@ -342,11 +262,17 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CheckableCommandItem,
} from '@/components/ui/command'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
import type { Project } from '@/services/project'
interface Props {
statusFilter: string[]
@@ -362,6 +288,10 @@ interface Props {
myTasksFilter: boolean
currentUserId: number | null
isDetailPanelEnabled: boolean
// Cross-project task lists (e.g. My Tasks) pass a project list + filter; the
// project-scoped Tasks page omits these and the filter stays hidden.
projects?: Project[]
projectFilter?: number | null
}
const props = defineProps<Props>()
@@ -375,6 +305,7 @@ const emit = defineEmits<{
'update:search': [value: string]
'update:column-visibility': [value: VisibilityState]
'update:my-tasks-filter': [value: boolean]
'update:project-filter': [value: number | null]
'toggle-detail-panel': []
}>()
@@ -387,6 +318,12 @@ const statusOptions = [
{ value: 'retake', label: 'Retake' },
]
const contextFilterOptions = [
{ value: 'all', label: 'All' },
{ value: 'shots', icon: Film },
{ value: 'assets', icon: Package },
]
// Column definitions
const allColumns = [
{ id: 'name', label: 'Task Name' },
@@ -410,23 +347,13 @@ const hasFilters = computed(() => {
props.assigneeFilter.length > 0 ||
props.contextFilter !== 'all' ||
props.search !== '' ||
props.myTasksFilter
props.myTasksFilter ||
(props.projectFilter ?? null) !== null
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleStatusFilter = (status: string) => {
@@ -450,11 +377,6 @@ const toggleAssigneeFilter = (assigneeId: number) => {
emit('update:assignee-filter', newFilter)
}
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleMyTasksFilter = () => {
const newValue = !props.myTasksFilter
emit('update:my-tasks-filter', newValue)
@@ -476,5 +398,8 @@ const clearFilters = () => {
emit('update:context-filter', 'all')
emit('update:search', '')
emit('update:my-tasks-filter', false)
if (props.projects) {
emit('update:project-filter', null)
}
}
</script>
+21 -14
View File
@@ -1,7 +1,8 @@
<template>
<div class="rounded-md border">
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden" @contextmenu.prevent>
<Table>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -33,7 +34,7 @@
]"
@click="handleRowClick(row.original, $event, index)"
@dblclick="handleRowDoubleClick(row.original)"
@contextmenu="handleContextMenu($event, index)"
@contextmenu="handleContextMenu($event, row.original)"
>
<TableCell
v-for="cell in row.getVisibleCells()"
@@ -61,6 +62,7 @@
</TableBody>
</Table>
</div>
</div>
</template>
<script setup lang="ts">
@@ -86,6 +88,7 @@ import {
import { createColumns } from './columns'
import { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
// Props interface
interface Props {
@@ -110,6 +113,8 @@ interface Emits {
const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore()
// Internal state
const sorting = ref<SortingState>([{ id: 'created_at', desc: true }])
const rowSelection = ref<RowSelectionState>({})
@@ -139,6 +144,14 @@ const columns = createColumns({
// row-double-click actually opens the panel, so reuse that instead.
onViewDetails: (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
@@ -217,14 +230,16 @@ const handleRowClick = (task: Task, event: MouseEvent, index: number) => {
const end = Math.max(lastClickedIndex.value, index)
const newSelection: Record<string, boolean> = {}
// Index into the sorted/rendered row model, not props.tasks the raw prop array
// order doesn't match the displayed (sorted) order.
const displayedRows = table.getRowModel().rows
for (let i = start; i <= end; i++) {
const id = String(props.tasks[i].id)
const id = String(displayedRows[i].original.id)
newSelection[id] = true
}
// Update rowSelection - create completely new object to trigger reactivity
rowSelection.value = newSelection
console.log('Shift-click selection updated:', rowSelection.value)
lastClickedIndex.value = index
} else if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd+Click: Toggle selection
@@ -250,17 +265,9 @@ const handleRowDoubleClick = (task: Task) => {
emit('row-double-click', task)
}
const handleContextMenu = (event: MouseEvent, index: number) => {
const handleContextMenu = (event: MouseEvent, rightClickedTask: Task) => {
event.preventDefault()
// Prevent context menu on empty table areas
if (props.tasks.length === 0) {
return
}
const rightClickedTask = props.tasks[index]
if (!rightClickedTask) return
const taskId = String(rightClickedTask.id)
// If right-clicked row is not selected, add it to selection
+22 -11
View File
@@ -15,10 +15,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} 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 { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
function formatDate(dateString: string): string {
const date = new Date(dateString)
@@ -35,6 +36,10 @@ interface ColumnCallbacks {
getSelectedCount?: () => number
onViewDetails?: (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>[] => {
@@ -143,18 +148,21 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
Button,
{
variant: 'outline',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
},
() => h(ChevronDown, { class: 'h-4 w-4' })
),
}),
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
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`),
...Object.values(TaskStatus).map((status) =>
...allStatusOptions.map((statusOption) =>
h(
Button,
{
@@ -162,11 +170,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
size: 'sm',
class: 'justify-start',
onClick: () => {
callbacks?.onBulkStatusChange?.(status)
callbacks?.onBulkStatusChange?.(statusOption.id as TaskStatus)
isPopoverOpen.value = false
},
},
() => h(TaskStatusBadge, { status, class: 'w-full' })
() => h(TaskStatusBadge, { status: statusOption, compact: true, class: 'w-full' })
)
),
])
@@ -334,8 +342,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -354,7 +361,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
DropdownMenuContent,
{ 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(
DropdownMenuItem,
{
@@ -387,7 +398,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
],
}
),
],
]),
}
),
],
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Checkbox } from "@/components/ui/checkbox"
import CommandItem from "./CommandItem.vue"
interface Props {
value: string
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:modelValue": [value: boolean]
}>()
</script>
<template>
<CommandItem :value="value" @select="emit('update:modelValue', !modelValue)">
<Checkbox
:model-value="modelValue"
tabindex="-1"
class="mr-2 pointer-events-none"
/>
<slot />
</CommandItem>
</template>
@@ -2,6 +2,7 @@ import type { Ref } from "vue"
import { createContext } from "reka-ui"
export { default as Command } from "./Command.vue"
export { default as CheckableCommandItem } from "./CheckableCommandItem.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
@@ -19,24 +19,15 @@
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in baseColumns" :key="column.id">
<CommandItem
<CheckableCommandItem
v-for="column in baseColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
:model-value="isColumnVisible(column.id)"
@update:model-value="(value) => toggleColumn(column.id, value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -62,24 +53,15 @@
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in taskColumns" :key="column.id">
<CommandItem
<CheckableCommandItem
v-for="column in taskColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
:model-value="isColumnVisible(column.id)"
@update:model-value="(value) => toggleColumn(column.id, value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -136,14 +118,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Check, ChevronDown, ChevronRight, Columns, ListChecks } from 'lucide-vue-next'
import { ChevronDown, ChevronRight, Columns, ListChecks } from 'lucide-vue-next'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import {
Collapsible,
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
props.class,
)"
>
<span class="truncate">
<span class="inline-flex items-center justify-center">
<slot />
</span>
</TabsTrigger>
@@ -1,21 +1,33 @@
<template>
<textarea
v-model="modelValue"
: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',
props.class
)"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
interface Props {
class?: string
modelValue?: string
defaultValue?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const emits = defineEmits<{
(e: 'update:modelValue', payload: string): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
@@ -114,7 +114,10 @@
</div>
</TableCell>
<TableCell>
<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>
<Badge v-if="user.is_admin" variant="destructive">Admin</Badge>
@@ -144,6 +147,10 @@
<Key class="h-4 w-4 mr-2" />
Reset Password
</DropdownMenuItem>
<DropdownMenuItem @click="handleManageRoles(user)">
<ShieldCheck class="h-4 w-4 mr-2" />
Manage Roles
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="!user.is_approved"
@@ -216,7 +223,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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";
interface Props {
@@ -232,6 +239,7 @@ interface Emits {
(e: "approveUser", userId: number): void;
(e: "editUser", user: User): void;
(e: "resetPassword", user: User): void;
(e: "manageRoles", user: User): void;
(e: "deleteUser", user: User): void;
}
@@ -378,6 +386,10 @@ const handleResetPassword = (user: User) => {
emit("resetPassword", user);
};
const handleManageRoles = (user: User) => {
emit("manageRoles", user);
};
const handleDeleteUser = (user: 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>
@@ -0,0 +1,41 @@
import type { Ref } from 'vue'
interface UseAsyncActionOptions {
isLoading: Ref<boolean>
error: Ref<string | null>
}
interface RunOptions {
/** Fallback message shown when the error has no server-provided detail */
errorMessage: string
/** Whether to re-throw after recording the error (default true) */
rethrow?: boolean
}
/**
* Wraps the isLoading/error/try-catch-finally shape shared by most store actions:
* set loading, clear error, run fn, extract a message from an axios-shaped or
* plain Error on failure, log it, optionally re-throw, and always clear loading.
*/
export function useAsyncAction({ isLoading, error }: UseAsyncActionOptions) {
// Overloads so callers that don't pass `rethrow: false` keep a non-optional return type
function run<T>(fn: () => Promise<T>, options: RunOptions & { rethrow?: true }): Promise<T>
function run<T>(fn: () => Promise<T>, options: RunOptions & { rethrow: false }): Promise<T | undefined>
async function run<T>(fn: () => Promise<T>, options: RunOptions): Promise<T | undefined> {
const { errorMessage, rethrow = true } = options
try {
isLoading.value = true
error.value = null
return await fn()
} catch (err: any) {
error.value = err?.response?.data?.detail || (err instanceof Error ? err.message : errorMessage)
console.error(errorMessage, err)
if (rethrow) throw err
return undefined
} finally {
isLoading.value = false
}
}
return { run }
}

Some files were not shown because too many files have changed in this diff Show More