Compare commits

..

37 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
indigo cd3628a255 Phase 2: asset/shot feature parity, task actions, member management
Phase 2 of frontend_tasks.md - close feature gaps between domains:

- Asset user-assignment popover, ported from shot's EditableTaskStatus,
  wired through columns.ts and AssetBrowser.vue.
- Asset column-locking toggle: two-pane frozen-column layout ported
  into AssetsDataTable.vue (adapted for asset's page-scroll layout,
  which has no bounded-height container like shot's).
- Task table row-actions menu (View Details + Reassign only - no
  Delete/Edit, since no backend support exists for either).
- Real select-task/create-task behavior on asset and shot detail
  panels: clicking a task swaps in the actual TaskDetailPanel in
  place; "Add Task" opens a task-type picker that creates real tasks
  via the existing createAssetTask/createShotTask services.
- create-note/upload-reference/publish-version implemented via a
  task-picker that deep-links into TaskDetailPanel's Notes/
  Attachments/Submissions tabs (new initialTab prop), reusing the
  already-working task-level components instead of building three new
  bespoke forms. Also fixed shot's pre-existing dead "Add Note"/
  "Upload Reference" buttons the same way.
- Consolidated ProjectMembersManager.vue and ProjectMemberManagement.vue
  into one component, combining remove-confirmation and approved-user
  filtering with toast feedback and the shared Select/Dialog UI kit.
- Wired ProjectDetailView's "Manage Members" to navigate to the
  project's Settings > Team tab.

Also fixed a bug in this session's own new code: TaskBrowser.vue's
row-click handler is a no-op by design, so the new row-actions menu
needed to emit row-double-click (which actually opens the panel)
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 08:45:06 +08:00
indigo e8b26487db Add task_details to the single-asset endpoint response
GET /assets/{id} never returned task_details (the schema didn't even
declare the field), unlike GET /shots/{id} which already populated it
correctly. This silently broke the asset detail panel's task list -
surfaced while wiring real task data into it on the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 08:44:36 +08:00
indigo cd2efe3587 Truth-in-UI cleanup: wire real dashboard data, fix delete/dialog gaps
Phase 1 of frontend_tasks.md - stop showing fabricated/broken UI:

- Dashboard now fetches real stats (projects, tasks, users, pending
  approvals, API keys, developer stats, pending reviews, admin
  activity) instead of hardcoded numbers. Added services/developer.ts
  and services/review.ts wrappers for previously-unused backend
  endpoints.
- Wired ActivityFeed into every project's Overview page in place of
  the "coming soon" placeholder.
- Registered the missing /projects/:id/technical-specs route (view
  and service already existed, just unreachable).
- Fixed AssetDeleteConfirmDialog's raw styled divs to use the shared
  Alert component and wired it into AssetBrowser, matching
  ShotBrowser's impact-summary + type-to-confirm safety pattern
  (asset deletion was previously less safe than shot deletion).
- Fixed a shared bug in both delete dialogs where the impact-summary
  section never rendered (watch on the open prop needed
  { immediate: true }).
- Replaced native confirm()/alert() with styled AlertDialog/Dialog in
  NoteItem, TaskAttachments, and UserMenu's keyboard-shortcuts item.
- Removed dead-end UI: Google OAuth stub buttons, UserMenu items
  pointing at non-existent routes, the /developer/docs dead link, and
  the no-op action button on the API Keys placeholder page.
- Removed leftover debug console logging across 8 files.

Added frontend_report.md (full audit) and frontend_tasks.md (phased
checklist) as the reference for this and future phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 04:47:26 +08:00
indigo c63a71883b Fix broken joinedload and import path in reviews/activities endpoints
joinedload("reviewer") used a string instead of a class-bound
attribute (rejected by SQLAlchemy 2.x), and models.submission was the
wrong import path for Submission. Both endpoints 500'd whenever
actually called; surfaced while wiring real dashboard/activity data
into the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 04:46:43 +08:00
indigo 61ee1a7364 Remove leftover debug/test scratch files from frontend root
These were one-off HTML/JS files and status reports accumulated from
past debugging sessions, not part of the app or a maintained test
suite.
2026-07-17 01:26:24 +08:00
indigo 14320693c7 Collapse sidebar column controls into icon dropdowns
When the sidebar is collapsed to icon-rail mode, the Columns/Task
Columns checkbox lists had no icon-only representation. Show icon
buttons that open a dropdown menu with checkbox items instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 01:23:18 +08:00
indigo d44398033a Add shot table column lock with two-pane horizontal scroll
Add a lock toggle to the shot table toolbar that freezes the
checkbox, thumbnail, and shot name columns. When locked, the table
splits into a fixed left pane and a horizontally scrollable right
pane so the scrollbar spans only the movable columns. Constrain the
table to fill the viewport height so the scrollbar stays at the
screen bottom, and add min-w-0 to the layout to keep horizontal
overflow inside the table instead of the page.

Also fix the asset Task Status filter to render task type groups by
using the object-aware TaskStatusBadge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:04:55 +08:00
indigo b3ee14cc6e Match asset page layout and toolbar to shot page
- Remove wrapper padding from ProjectAssetsView so toolbar sits flush
  under the header, matching the shot page
- Add px-4 to AssetsDataTable root div to match ShotsDataTable margin
- Move thumbnail toggle from left filter group to right action area as
  an icon-only button, matching the shot toolbar's right-side pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 01:29:27 +08:00
indigo 0acf9ddff2 Optimize shot table loading performance
- Pass episode_id filter to backend API instead of client-side filtering,
  reducing payload size when an episode is selected
- Skip getShot refetch in ShotDetailPanel when initialShot prop is provided
- Fix redundant DB query in list_shots: read project.custom_task_statuses
  directly from the already-fetched project object
- Add missing indexes on Task.shot_id, Task.assigned_user_id,
  Task.deleted_at and Episode.project_id; add add_perf_indexes.py
  migration script to apply them to existing databases
- Center login page layout
- Update CLAUDE.md and AGENTS.md with correct venv path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:30:18 +08:00
296 changed files with 11658 additions and 35826 deletions
+3 -1
View File
@@ -33,9 +33,11 @@ LinkDesk is a VFX/animation production management system: FastAPI backend + Vue
### Backend
```bash
# venv lives at repo root, not inside backend/
cd backend
# Windows: .venv\Scripts\activate | bash/mac: source .venv/bin/activate
..\venv\Scripts\activate # Windows PowerShell
uvicorn main:app --reload --port 8000
# or invoke directly: D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000
```
### Frontend
+4 -2
View File
@@ -34,9 +34,11 @@ LinkDesk is a VFX/animation production management system. It has a FastAPI backe
### Backend
```bash
# venv is at repo root, not inside backend/
cd backend
# Activate venv first (Windows)
.venv\Scripts\activate # or: source .venv/bin/activate on bash
..\venv\Scripts\activate # Windows PowerShell
# or run directly:
# D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000
uvicorn main:app --reload --port 8000
```
+18
View File
@@ -0,0 +1,18 @@
"""One-time migration: add performance indexes for shot table queries."""
from sqlalchemy import text
from database import engine
indexes = [
"CREATE INDEX IF NOT EXISTS ix_tasks_shot_id ON tasks (shot_id)",
"CREATE INDEX IF NOT EXISTS ix_tasks_assigned_user_id ON tasks (assigned_user_id)",
"CREATE INDEX IF NOT EXISTS ix_tasks_deleted_at ON tasks (deleted_at)",
"CREATE INDEX IF NOT EXISTS ix_episodes_project_id ON episodes (project_id)",
]
with engine.connect() as conn:
for sql in indexes:
conn.execute(text(sql))
print(f"OK: {sql}")
conn.commit()
print("Done.")
+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"
]
+1 -1
View File
@@ -17,7 +17,7 @@ class Episode(Base):
__tablename__ = "episodes"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
name = Column(String, nullable=False, index=True)
description = Column(String)
episode_number = Column(Integer, nullable=False)
+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})>"
+11 -3
View File
@@ -38,25 +38,32 @@ class AttachmentType(str, enum.Enum):
DOCUMENTATION = "documentation"
class NoteType(str, enum.Enum):
INTERNAL = "internal"
CLIENT = "client"
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
episode_id = Column(Integer, ForeignKey("episodes.id"), nullable=True)
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True)
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True)
assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
task_type = Column(String, nullable=False) # Changed from Enum to String to support custom task types
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
department = Column(String, nullable=True) # Standard or project-custom department, independent of assignee
start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Soft deletion columns
deleted_at = Column(DateTime(timezone=True), nullable=True)
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
@@ -190,6 +197,7 @@ class ProductionNote(Base):
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
note_type = Column(Enum(NoteType), nullable=False, default=NoteType.INTERNAL)
parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+1 -10
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
+25 -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()
@@ -477,6 +463,15 @@ async def get_asset(
asset_data = AssetResponse.model_validate(asset)
asset_data.task_count = task_count
asset_data.task_details = [
TaskStatusInfo(
task_type=task.task_type,
status=task.status,
task_id=task.id,
assigned_user_id=task.assigned_user_id
)
for task in active_tasks
]
return asset_data
@@ -527,7 +522,7 @@ async def create_asset_task(
asset_id: int,
task_type: str, # Changed from TaskType enum to str
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task for an asset"""
# Exclude soft deleted assets
@@ -560,13 +555,15 @@ async def create_asset_task(
# Create the task
task_name = f"{asset.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == asset.project_id).first()
db_task = Task(
project_id=asset.project_id,
asset_id=asset.id,
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {asset.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
@@ -586,7 +583,7 @@ async def update_asset(
asset_id: int,
asset_update: AssetUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'edit'))
):
"""Update an asset"""
# Exclude soft deleted assets
@@ -642,7 +639,7 @@ async def update_asset(
async def get_asset_deletion_info(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'delete'))
):
"""Get information about what will be deleted when deleting an asset"""
# Exclude soft deleted assets
@@ -692,7 +689,7 @@ async def get_asset_deletion_info(
async def delete_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('asset', 'delete'))
):
"""Soft delete an asset and all its associated data"""
# Exclude soft deleted assets
+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)
+6 -6
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()
@@ -66,7 +66,7 @@ async def get_pending_reviews(
query = db.query(Submission).options(
joinedload(Submission.user),
joinedload(Submission.task).joinedload(Task.project),
joinedload(Submission.reviews).joinedload("reviewer")
joinedload(Submission.reviews).joinedload(Review.reviewer)
).join(Task).filter(
Submission.deleted_at.is_(None),
Task.deleted_at.is_(None)
@@ -126,9 +126,9 @@ async def approve_submission(
submission_id: int,
review: ReviewCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_director_coordinator_or_admin)
current_user: User = Depends(require_permission('review', 'publish'))
):
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
"""Approve a submission. Requires review:publish permission (or admin)."""
submission = db.query(Submission).options(
joinedload(Submission.task)
@@ -189,9 +189,9 @@ async def request_retake(
submission_id: int,
review: ReviewCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_director_coordinator_or_admin)
current_user: User = Depends(require_permission('review', 'retake'))
):
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
"""Request a retake for a submission. Requires review:retake permission (or admin)."""
submission = db.query(Submission).options(
joinedload(Submission.task)
+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
+79 -29
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)
@@ -266,9 +252,17 @@ async def list_shots(
)
for project in projects:
custom_types = project.custom_shot_task_types or []
raw = project.custom_task_statuses
if isinstance(raw, str):
try:
import json
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
raw = []
custom_statuses = raw if isinstance(raw, list) else []
project_data[project.id] = {
'task_types': STANDARD_SHOT_TASK_TYPES + custom_types,
'custom_statuses': get_project_custom_statuses(project.id, db)
'custom_statuses': custom_statuses
}
# OPTIMIZATION: Group results by shot and aggregate task data efficiently
@@ -373,7 +367,7 @@ async def create_shot(
episode_id: int,
create_default_tasks: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'create'))
):
"""Create a new shot in an episode"""
# Check episode access
@@ -428,7 +422,8 @@ async def create_shot(
all_task_types = get_all_shot_task_types(episode.project_id, db)
# Use default standard types for now (can be customized via project settings)
default_task_types = get_default_shot_task_types()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
project = db.query(Project).filter(Project.id == episode.project_id).first()
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db, project)
db.commit()
task_count = len(created_tasks)
@@ -444,7 +439,7 @@ async def create_shots_bulk(
bulk_shot: BulkShotCreate,
episode_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'create'))
):
"""Create multiple shots with naming pattern and default tasks"""
# Check episode access
@@ -506,6 +501,7 @@ async def create_shots_bulk(
created_shots = []
total_tasks_created = 0
project = db.query(Project).filter(Project.id == project_id).first()
try:
# Create all shots - validation already done above
@@ -533,7 +529,7 @@ async def create_shots_bulk(
# Create default tasks if requested
task_count = 0
if bulk_shot.create_default_tasks:
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db, project)
task_count = len(created_tasks)
total_tasks_created += task_count
@@ -606,6 +602,58 @@ async def get_shot(
shot_data = ShotResponse.model_validate(shot)
shot_data.task_count = task_count
# Add project_name from episode.project (already eager loaded)
if shot.episode and shot.episode.project:
shot_data.project_name = shot.episode.project.name
# Add task status information (similar to list endpoint)
project = shot.episode.project if shot.episode else None
# Get all task types: standard + custom
from routers.shots import STANDARD_SHOT_TASK_TYPES
project_task_types = list(STANDARD_SHOT_TASK_TYPES) # Start with standard types
if project and project.custom_shot_task_types:
custom_types = project.custom_shot_task_types or []
if isinstance(custom_types, list):
for ct in custom_types:
if isinstance(ct, dict) and 'type' in ct:
project_task_types.append(ct['type'])
elif isinstance(ct, str):
project_task_types.append(ct)
# Initialize task_status and task_ids dictionaries
task_status_dict = {}
task_ids_dict = {}
task_details_list = []
# Initialize with default not_started for all project task types
for task_type_init in project_task_types:
task_status_dict[task_type_init] = "not_started"
# Build task information from active tasks
for task in active_tasks:
task_type = task.task_type.value if hasattr(task.task_type, 'value') else task.task_type
task_status = task.status.value if hasattr(task.status, 'value') else task.status
task_id = task.id
assigned_user_id = task.assigned_user_id
# Update task status
task_status_dict[task_type] = task_status
task_ids_dict[task_type] = task_id
# Add to task details
task_details_list.append(TaskStatusInfo(
task_type=task_type,
status=task_status,
task_id=task_id,
assigned_user_id=assigned_user_id
))
shot_data.task_status = task_status_dict
shot_data.task_ids = task_ids_dict
shot_data.task_details = task_details_list
return shot_data
@@ -614,7 +662,7 @@ async def create_shot_task(
shot_id: int,
task_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task for a shot"""
# Exclude soft deleted shots
@@ -650,6 +698,7 @@ async def create_shot_task(
# Create the task
task_name = f"{shot.name} - {task_type.title()}"
project = db.query(Project).filter(Project.id == shot.project_id).first()
db_task = Task(
project_id=shot.project_id,
episode_id=shot.episode_id,
@@ -657,7 +706,8 @@ async def create_shot_task(
task_type=task_type,
name=task_name,
description=f"{task_type.title()} task for {shot.name}",
status="not_started"
status="not_started",
department=find_owning_department(project, task_type)
)
db.add(db_task)
@@ -677,7 +727,7 @@ async def update_shot(
shot_id: int,
shot_update: ShotUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'edit'))
):
"""Update a shot"""
from sqlalchemy.orm import selectinload
@@ -758,7 +808,7 @@ async def update_shot(
async def get_shot_deletion_info(
shot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'delete'))
):
"""Get information about what will be deleted when deleting a shot"""
# Exclude soft deleted shots
@@ -808,7 +858,7 @@ async def get_shot_deletion_info(
async def delete_shot(
shot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_coordinator_or_admin)
current_user: User = Depends(require_permission('shot', 'delete'))
):
"""Soft delete a shot and all its associated data"""
# Exclude soft deleted shots
+205 -46
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
+8 -7
View File
@@ -25,6 +25,13 @@ class AssetUpdate(BaseModel):
status: Optional[AssetStatus] = None
class TaskStatusInfo(BaseModel):
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class AssetResponse(AssetBase):
id: int
project_id: int
@@ -33,18 +40,12 @@ class AssetResponse(AssetBase):
# Summary information
task_count: int = 0
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
class Config:
from_attributes = True
class TaskStatusInfo(BaseModel):
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class AssetListResponse(BaseModel):
id: int
name: str
+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]
+13 -8
View File
@@ -28,6 +28,14 @@ class ShotUpdate(BaseModel):
project_id: Optional[int] = Field(None, description="Project ID - must match episode's project")
class TaskStatusInfo(BaseModel):
"""Task status information for table display"""
task_type: str # String to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class ShotResponse(ShotBase):
id: int
project_id: int # Make required in response
@@ -41,18 +49,15 @@ class ShotResponse(ShotBase):
# Optional computed field for display
project_name: Optional[str] = Field(None, description="Project name for display purposes")
# Task status information for detail display
task_status: Dict[str, Optional[str]] = Field(default_factory=dict, description="Task status by task type")
task_ids: Dict[str, int] = Field(default_factory=dict, description="Task IDs by task type")
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
class Config:
from_attributes = True
class TaskStatusInfo(BaseModel):
"""Task status information for table display"""
task_type: str # String to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class ShotListResponse(BaseModel):
id: int
name: str
+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
+1 -2
View File
@@ -302,10 +302,9 @@ class ActivityService:
"""Get activities excluding those related to deleted records."""
from sqlalchemy import and_, or_, desc
from datetime import datetime, timedelta
from models.task import Task
from models.task import Task, Submission
from models.shot import Shot
from models.asset import Asset
from models.submission import Submission
query = db.query(Activity)
+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)
-109
View File
@@ -1,109 +0,0 @@
# Assignment Popover Implementation - Final Status
## ✅ Implementation Complete
The EditableTaskStatus component has been successfully updated with comprehensive fixes for the assignment popover functionality. All major issues have been addressed.
## 🔧 Key Fixes Applied
### 1. Event Handling
- **Event Prevention Wrapper**: Added `<div @click.stop>` around the popover to prevent table row click interference
- **Proper Event Propagation**: Removed custom click handlers that were conflicting with Popover's natural behavior
### 2. Z-Index and Positioning
- **Button Z-Index**: Added `z-10` to the assignment button for proper layering
- **Popover Z-Index**: Added `z-50` to popover content for top-level display
- **Positioning**: Added `side="bottom"` and `:side-offset="4"` for better popover placement
### 3. Data Loading Strategy
- **Watcher-Based Loading**: Project members are loaded when popover opens using Vue watcher
- **Preload Strategy**: Members are also preloaded on component mount for faster access
- **Loading States**: Proper loading indicators and error handling
### 4. Debug and Error Handling
- **Debug Information**: Shows project ID and member count in popover
- **Loading States**: Spinner and "Loading members..." message
- **Error States**: "No project members found" with retry button
- **Console Logging**: Comprehensive logging for debugging
### 5. Assignment Logic
- **Task Creation**: Automatically creates tasks if they don't exist
- **Assignment API**: Uses `taskService.assignTask()` for user assignment
- **Unassignment API**: Uses `taskService.updateTask()` with `assigned_user_id: 0`
- **Visual Feedback**: Avatar updates to show assigned user
- **Notifications**: Success toast messages for assignment/unassignment
## 🎯 Component Features
### Visual Elements
- **Unassigned Tasks**: Show 👤 User icon
- **Assigned Tasks**: Show 🔵 Avatar with user initials
- **Loading State**: Spinner overlay during updates
- **Disabled State**: Button disabled during API calls
### Popover Content
- **Header**: "Assign Task" title
- **Debug Info**: Project ID and member count
- **Loading State**: Spinner with "Loading members..." text
- **Error State**: "No project members found" with retry button
- **Unassign Option**: "Unassign" button with UserX icon
- **Member List**: Scrollable list with avatars, names, and roles
### Functionality
- **Smart Loading**: Only loads members when popover opens (if not already loaded)
- **Retry Logic**: Retry button for failed member loading
- **Assignment**: Click member to assign task
- **Unassignment**: Click "Unassign" to remove assignment
- **Auto-Close**: Popover closes after successful assignment
- **Toast Notifications**: Success/error messages
## 🧪 Testing Checklist
### Manual Testing Steps
1. **Navigate to Shots Table**: Go to any project's shots page in table view
2. **Locate Assignment Buttons**: Look for 👤 or 🔵 icons in task columns
3. **Click Assignment Button**: Verify popover opens immediately
4. **Check Content Loading**: Verify debug info, loading states, and member list
5. **Test Assignment**: Click a member and verify assignment works
6. **Test Unassignment**: Click "Unassign" and verify it works
7. **Check Visual Updates**: Verify button shows correct icon/avatar
8. **Verify Notifications**: Check for success toast messages
### Console Verification
Expected console messages when clicking assignment button:
```
Popover state changed: true
Loading project members when popover opens
Loading project members for project: [ID]
Loaded project members: [...]
```
### Network Verification
Expected API calls:
- `GET /projects/{id}/members` - Load project members
- `POST /shots/{id}/tasks` - Create task if needed
- `PUT /tasks/{id}/assign` - Assign task to user
- `PUT /tasks/{id}` - Update task (for unassignment)
## 🚀 Ready for Production
The assignment popover functionality is now fully implemented and ready for use. The component includes:
- ✅ Robust event handling
- ✅ Proper z-index management
- ✅ Smart data loading
- ✅ Comprehensive error handling
- ✅ Visual feedback and notifications
- ✅ Debug information for troubleshooting
- ✅ Full assignment/unassignment workflow
## 🔍 Troubleshooting
If issues occur, check:
1. Browser console for JavaScript errors
2. Network tab for failed API calls
3. User permissions (coordinator/admin required)
4. Backend service availability
5. Project member data availability
The implementation is comprehensive and should handle all common use cases and edge cases gracefully.
-124
View File
@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Clear Auth & Reload</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
margin: 10px 5px;
}
button:hover {
background: #0056b3;
}
.danger {
background: #dc3545;
}
.danger:hover {
background: #c82333;
}
.success {
background: #28a745;
}
.success:hover {
background: #218838;
}
.info {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
pre {
background: #f4f4f4;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>🔧 Fix Shot Detail 403 Error</h1>
<div class="info">
<h3>Current Issue:</h3>
<p>You're getting 403 errors when clicking on shots, even though you're logged in as admin.</p>
<p><strong>Solution:</strong> Clear your authentication tokens and login again.</p>
</div>
<h2>Step 1: Check Current Auth State</h2>
<button onclick="checkAuth()">Check Auth</button>
<pre id="authState"></pre>
<h2>Step 2: Clear All Auth Data</h2>
<button class="danger" onclick="clearAuth()">Clear Auth Tokens</button>
<pre id="clearResult"></pre>
<h2>Step 3: Reload Application</h2>
<button class="success" onclick="reloadApp()">Reload & Login Again</button>
<script>
function checkAuth() {
const authState = {
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken'),
user: localStorage.getItem('user')
};
document.getElementById('authState').textContent = JSON.stringify(authState, null, 2);
if (authState.user) {
try {
const user = JSON.parse(authState.user);
console.log('User data:', user);
} catch (e) {
console.error('Failed to parse user data:', e);
}
}
}
function clearAuth() {
// Clear all auth-related items
const keysToRemove = [
'token',
'refreshToken',
'user',
'access_token',
'refresh_token'
];
keysToRemove.forEach(key => {
localStorage.removeItem(key);
sessionStorage.removeItem(key);
});
document.getElementById('clearResult').textContent = '✓ All auth tokens cleared!\n\nCleared items:\n' + keysToRemove.join('\n');
}
function reloadApp() {
// Clear auth first
clearAuth();
// Redirect to login page
setTimeout(() => {
window.location.href = 'http://localhost:5173/login';
}, 500);
}
// Auto-check on load
window.onload = checkAuth;
</script>
</body>
</html>
-175
View File
@@ -1,175 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clear Expired Tokens</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #1a365d;
margin-bottom: 10px;
}
.warning {
background: #fef3c7;
border: 2px solid #f59e0b;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #92400e;
}
.success {
background: #d1fae5;
border: 2px solid #10b981;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #065f46;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #2563eb;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.info {
background: #eff6ff;
border: 2px solid #3b82f6;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #1e40af;
}
.token-info {
font-family: monospace;
font-size: 12px;
background: #f3f4f6;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<h1>🔐 Clear Expired Tokens</h1>
<div class="warning">
<strong>⚠️ Token Expiration Issue</strong><br>
Your JWT tokens have expired. This is causing the "Signature has expired" error when trying to log in.
</div>
<div id="tokenStatus" class="info">
<strong>Current Token Status:</strong>
<div id="tokenDetails"></div>
</div>
<button id="clearBtn" onclick="clearTokens()">Clear Expired Tokens</button>
<div id="result" style="display: none;"></div>
<div class="info" style="margin-top: 30px;">
<strong>What this does:</strong><br>
• Removes access_token from localStorage<br>
• Removes refresh_token from localStorage<br>
• Allows you to log in fresh with valid credentials
</div>
<div class="info">
<strong>After clearing tokens:</strong><br>
1. Click the "Clear Expired Tokens" button above<br>
2. Navigate to <a href="http://localhost:5173/login">http://localhost:5173/login</a><br>
3. Log in with your credentials<br>
4. You should now be able to access the application
</div>
</div>
<script>
function checkTokens() {
const accessToken = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');
const detailsDiv = document.getElementById('tokenDetails');
if (!accessToken && !refreshToken) {
detailsDiv.innerHTML = '<div class="token-info">✅ No tokens found - you can log in fresh!</div>';
document.getElementById('clearBtn').disabled = true;
document.getElementById('clearBtn').textContent = 'No Tokens to Clear';
} else {
let html = '';
if (accessToken) {
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]));
const exp = new Date(payload.exp * 1000);
const now = new Date();
const expired = exp < now;
html += `<div class="token-info">
<strong>Access Token:</strong> ${expired ? '❌ EXPIRED' : '✅ Valid'}<br>
Expires: ${exp.toLocaleString()}<br>
${expired ? `Expired ${Math.floor((now - exp) / (1000 * 60 * 60 * 24))} days ago` : ''}
</div>`;
} catch (e) {
html += '<div class="token-info">❌ Access Token: Invalid format</div>';
}
}
if (refreshToken) {
html += '<div class="token-info">🔄 Refresh Token: Present (likely expired)</div>';
}
detailsDiv.innerHTML = html;
}
}
function clearTokens() {
const accessToken = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');
if (!accessToken && !refreshToken) {
showResult('No tokens to clear!', 'info');
return;
}
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
showResult('✅ Tokens cleared successfully! You can now log in fresh.', 'success');
setTimeout(() => {
checkTokens();
}, 500);
}
function showResult(message, type) {
const resultDiv = document.getElementById('result');
resultDiv.className = type;
resultDiv.innerHTML = message;
resultDiv.style.display = 'block';
}
// Check tokens on page load
checkTokens();
</script>
</body>
</html>
-169
View File
@@ -1,169 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Final Implementation Verification</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.success { color: #22c55e; font-weight: bold; }
.error { color: #ef4444; font-weight: bold; }
.info { color: #3b82f6; font-weight: bold; }
.step {
margin: 20px 0;
padding: 15px;
border-left: 4px solid #22c55e;
background-color: #f0fdf4;
}
.highlight {
background-color: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
}
.checklist {
background-color: #f0f9ff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.checklist ul {
margin: 0;
padding-left: 20px;
}
.checklist li {
margin: 8px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>✅ Final Implementation Verification</h1>
<div class="step">
<h2>🎯 All Requirements Successfully Implemented</h2>
<p>The task status implementation has been completed with all requested improvements:</p>
</div>
<div class="checklist">
<h3>✅ 1. Consistent Task Status Badge Width</h3>
<ul>
<li><strong>Implementation:</strong> Fixed width of <span class="highlight">130px</span> for all task status badges</li>
<li><strong>Location:</strong> <code>EditableTaskStatus.vue</code> - SelectTrigger class: <code>w-[130px]</code></li>
<li><strong>Benefit:</strong> Perfect column alignment, no layout shifts when status changes</li>
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
</ul>
</div>
<div class="checklist">
<h3>✅ 2. Column Visibility Dropdown Menu</h3>
<ul>
<li><strong>New Component:</strong> <code>ColumnVisibilityControl.vue</code> created</li>
<li><strong>Features:</strong>
<ul>
<li>Individual control for each column (Name, Category, Status, Task Count)</li>
<li>Separate controls for task status columns (Modeling, Surfacing, Rigging)</li>
<li>Additional columns (Description, Updated)</li>
<li>Organized dropdown with sections and labels</li>
</ul>
</li>
<li><strong>Persistence:</strong> Settings saved in session storage with deep watching</li>
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
</ul>
</div>
<div class="checklist">
<h3>✅ 3. Removed Show Task Status Checkbox</h3>
<ul>
<li><strong>Removed:</strong> <code>TaskStatusToggle.vue</code> component deleted</li>
<li><strong>Replaced:</strong> Simple checkbox replaced with comprehensive column control</li>
<li><strong>Updated:</strong> AssetBrowser.vue updated to use new ColumnVisibilityControl</li>
<li><strong>Cleaned:</strong> All references to showTaskStatus removed</li>
<li><strong>Status:</strong> <span class="success">✅ COMPLETE</span></li>
</ul>
</div>
<div class="step">
<h2>🧪 How to Test</h2>
<ol>
<li><strong>Open Application:</strong> <span class="highlight">http://localhost:5174</span></li>
<li><strong>Login:</strong> <span class="highlight">admin@vfx.com / admin123</span></li>
<li><strong>Navigate:</strong> Project → Assets tab → List view</li>
<li><strong>Verify Badge Width:</strong> All task status badges should be exactly the same width</li>
<li><strong>Test Column Control:</strong> Click "Columns" dropdown to show/hide individual columns</li>
<li><strong>Test Persistence:</strong> Change settings, refresh page, settings should be remembered</li>
</ol>
</div>
<div class="step">
<h2>🎨 Visual Improvements</h2>
<ul>
<li><strong>Professional Layout:</strong> Consistent column widths and alignment</li>
<li><strong>Flexible Display:</strong> Users can customize their view completely</li>
<li><strong>Smart Columns:</strong> Rigging column only shows when relevant assets exist</li>
<li><strong>Better UX:</strong> Granular control instead of simple on/off toggle</li>
<li><strong>Clean Interface:</strong> Organized dropdown with clear sections</li>
</ul>
</div>
<div class="step">
<h2>🔧 Technical Details</h2>
<ul>
<li><strong>Badge Width:</strong> <code>w-[130px]</code> ensures consistent sizing</li>
<li><strong>Column Headers:</strong> Fixed width <code>w-[140px]</code> for task status columns</li>
<li><strong>State Management:</strong> <code>visibleColumns</code> reactive object with session persistence</li>
<li><strong>Smart Display:</strong> <code>hasRiggingAssets</code> computed property for conditional rendering</li>
<li><strong>Deep Watching:</strong> Column changes automatically saved to session storage</li>
</ul>
</div>
<div id="status" class="step">
<h2>🚀 System Status</h2>
<p id="status-text">Checking system readiness...</p>
</div>
<div class="step">
<h2>🎉 Implementation Complete!</h2>
<p>All requested features have been successfully implemented and tested. The asset browser now provides:</p>
<ul>
<li>✅ Consistent task status badge widths</li>
<li>✅ Comprehensive column visibility control</li>
<li>✅ Professional, customizable interface</li>
<li>✅ Persistent user preferences</li>
<li>✅ Error-free operation</li>
</ul>
</div>
</div>
<script>
async function checkStatus() {
const statusText = document.getElementById('status-text');
try {
const response = await fetch('http://localhost:8000/health');
if (response.ok) {
statusText.innerHTML = '<span class="success">✅ System Ready - All features available for testing!</span>';
} else {
statusText.innerHTML = '<span class="error">❌ Backend issue: ' + response.status + '</span>';
}
} catch (error) {
statusText.innerHTML = '<span class="error">❌ Backend not running. Start with: cd backend && python main.py</span>';
}
}
checkStatus();
</script>
</body>
</html>
-200
View File
@@ -1,200 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Implementation Summary</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.success { color: #22c55e; font-weight: bold; }
.error { color: #ef4444; font-weight: bold; }
.info { color: #3b82f6; font-weight: bold; }
.step {
margin: 20px 0;
padding: 15px;
border-left: 4px solid #22c55e;
background-color: #f0fdf4;
}
.highlight {
background-color: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
}
.checklist {
background-color: #f0f9ff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.checklist ul {
margin: 0;
padding-left: 20px;
}
.checklist li {
margin: 8px 0;
}
.code {
background-color: #1f2937;
color: #f9fafb;
padding: 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>✅ Implementation Summary - Asset Browser Enhancements</h1>
<div class="step">
<h2>🔧 Issues Fixed</h2>
<ul>
<li><strong>Columns Dropdown Not Working:</strong> Fixed duplicate import in AssetBrowser.vue</li>
<li><strong>Task Status Badge Width:</strong> Standardized to 130px for consistent alignment</li>
<li><strong>Column Control:</strong> Implemented comprehensive dropdown menu for individual column visibility</li>
</ul>
</div>
<div class="checklist">
<h3>✅ New Features Implemented</h3>
<h4>1. Fixed Column Visibility Control</h4>
<ul>
<li><strong>ColumnVisibilityControl.vue:</strong> Comprehensive dropdown menu component</li>
<li><strong>Individual Controls:</strong> Name, Category, Status, Thumbnail, Task Status, Description, Updated</li>
<li><strong>Organized Sections:</strong> Basic columns, Task Status columns, Other columns</li>
<li><strong>Session Persistence:</strong> User preferences saved automatically</li>
</ul>
<h4>2. Thumbnail Column Added</h4>
<ul>
<li><strong>Thumbnail Column:</strong> Visual preview column with placeholder icons</li>
<li><strong>ThumbnailToggle.vue:</strong> Separate toggle switch for thumbnail visibility</li>
<li><strong>Sync Functionality:</strong> Thumbnail toggle syncs with column visibility control</li>
<li><strong>Default Hidden:</strong> Thumbnails hidden by default to keep table clean</li>
</ul>
<h4>3. Task Count Column Removed</h4>
<ul>
<li><strong>Removed Task Count:</strong> Eliminated task count column from asset table</li>
<li><strong>Focus on Individual Status:</strong> Emphasis on individual task status rather than counts</li>
<li><strong>Cleaner Interface:</strong> More space for important columns</li>
</ul>
<h4>4. Consistent Badge Width</h4>
<ul>
<li><strong>Fixed Width:</strong> All task status badges now 130px wide</li>
<li><strong>Perfect Alignment:</strong> Columns line up properly</li>
<li><strong>Professional Appearance:</strong> No layout shifts when status changes</li>
</ul>
</div>
<div class="step">
<h2>📝 Documentation Updated</h2>
<ul>
<li><strong>Requirements.md:</strong> Updated Requirement 20 with thumbnail functionality and column control details</li>
<li><strong>Design.md:</strong> Enhanced Asset Table design section with thumbnail and column visibility specifications</li>
<li><strong>Tasks.md:</strong> Updated Task 12.6 title to include thumbnails</li>
</ul>
</div>
<div class="step">
<h2>🎯 Components Created/Modified</h2>
<div class="checklist">
<h4>New Components:</h4>
<ul>
<li><span class="code">ThumbnailToggle.vue</span> - Toggle switch for thumbnail column visibility</li>
</ul>
<h4>Modified Components:</h4>
<ul>
<li><span class="code">ColumnVisibilityControl.vue</span> - Added thumbnail option, fixed functionality</li>
<li><span class="code">AssetBrowser.vue</span> - Fixed imports, added thumbnail functionality, removed task count</li>
<li><span class="code">EditableTaskStatus.vue</span> - Consistent 130px width for badges</li>
</ul>
</div>
</div>
<div class="step">
<h2>🧪 Testing Instructions</h2>
<ol>
<li><strong>Open Application:</strong> <span class="highlight">http://localhost:5174</span></li>
<li><strong>Login:</strong> <span class="highlight">admin@vfx.com / admin123</span></li>
<li><strong>Navigate:</strong> Project → Assets tab → List view</li>
<li><strong>Test Column Control:</strong>
<ul>
<li>Click "Columns" dropdown - should open properly now</li>
<li>Toggle different columns on/off</li>
<li>Verify table updates immediately</li>
</ul>
</li>
<li><strong>Test Thumbnail Toggle:</strong>
<ul>
<li>Use "Show Thumbnails" checkbox</li>
<li>Verify thumbnail column appears/disappears</li>
<li>Check sync with column dropdown</li>
</ul>
</li>
<li><strong>Verify Badge Consistency:</strong>
<ul>
<li>All task status badges should be same width</li>
<li>Perfect column alignment</li>
<li>No task count column visible</li>
</ul>
</li>
</ol>
</div>
<div class="step">
<h2>🎉 Summary</h2>
<p>All requested features have been successfully implemented:</p>
<ul>
<li><strong>Fixed Columns Dropdown:</strong> Now working properly with comprehensive controls</li>
<li><strong>Consistent Badge Width:</strong> 130px width for perfect alignment</li>
<li><strong>Thumbnail Column:</strong> Added with toggle control and session persistence</li>
<li><strong>Removed Task Count:</strong> Cleaner focus on individual task status</li>
<li><strong>Updated Documentation:</strong> Requirements, design, and tasks all updated</li>
<li><strong>Build Success:</strong> No errors, ready for production</li>
</ul>
</div>
<div id="status" class="step">
<h2>🔍 System Status</h2>
<p id="status-text">Checking system readiness...</p>
</div>
</div>
<script>
async function checkStatus() {
const statusText = document.getElementById('status-text');
try {
const response = await fetch('http://localhost:8000/health');
if (response.ok) {
statusText.innerHTML = '<span class="success">✅ System Ready - All enhancements available for testing!</span>';
} else {
statusText.innerHTML = '<span class="error">❌ Backend issue: ' + response.status + '</span>';
}
} catch (error) {
statusText.innerHTML = '<span class="error">❌ Backend not running. Start with: cd backend && python main.py</span>';
}
}
checkStatus();
</script>
</body>
</html>
+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",
@@ -1,275 +0,0 @@
# Frontend Response Format Validation Report
## Task 11: Frontend Response Format Validation
**Status:** ✅ COMPLETED
**Date:** December 31, 2025
## Overview
This report validates that all optimized endpoints return embedded `task_statuses` field and that frontend components can consume the optimized data format, fulfilling Requirements 4.1, 4.2, and 4.3.
## Requirements Validation
### ✅ Requirement 4.1: Shot API Response Format
**VERIFIED:** Shot endpoints return embedded task_statuses field
**Evidence from `backend/routers/shots.py`:**
```python
# Lines 295-310: Shot response building
shot_response = ShotListResponse.model_validate(shot)
shot_response.task_count = len(shot_data['tasks'])
shot_response.task_status = shot_data['task_status'] # ✅ task_status field
shot_response.task_details = shot_data['task_details'] # ✅ task_details field
```
**Schema Validation from `backend/schemas/shot.py`:**
```python
class ShotListResponse(BaseModel):
# ... other fields ...
task_status: Dict[str, Optional[TaskStatus]] = Field(default_factory=dict, description="Task status by task type")
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
```
### ✅ Requirement 4.2: Asset API Response Format
**VERIFIED:** Asset endpoints return embedded task_statuses field
**Evidence from `backend/routers/assets.py`:**
```python
# Lines 295-310: Asset response building
asset_response = AssetListResponse.model_validate(asset)
asset_response.task_count = len(asset_data['tasks'])
asset_response.task_status = asset_data['task_status'] # ✅ task_status field
asset_response.task_details = asset_data['task_details'] # ✅ task_details field
```
**Schema Validation from `backend/schemas/asset.py`:**
```python
class AssetListResponse(BaseModel):
# ... other fields ...
task_status: Dict[str, Optional[TaskStatus]] = Field(default_factory=dict, description="Task status by task type")
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
```
### ✅ Requirement 4.3: Complete Task Status Information
**VERIFIED:** Task status data includes all required fields
**Evidence from `TaskStatusInfo` schema:**
```python
class TaskStatusInfo(BaseModel):
task_type: str # ✅ Task type included
status: str # ✅ Current status included
task_id: Optional[int] # ✅ Task ID included
assigned_user_id: Optional[int] # ✅ Assignee included
```
**Backend Implementation:**
```python
# Lines 285-290: Task details population
shots_dict[shot.id]['task_details'].append(TaskStatusInfo(
task_type=task_type, # ✅ Task type
status=task_status, # ✅ Current status
task_id=task_id, # ✅ Task ID
assigned_user_id=assigned_user_id # ✅ Assignee
))
```
## Frontend Component Compatibility
### ✅ ShotDetailPanel Component
**VERIFIED:** Component uses embedded task data without additional API calls
**Evidence from `frontend/src/components/shot/ShotDetailPanel.vue`:**
```typescript
// Lines 180-190: Optimized task loading
const loadTasks = () => {
// Use task_details already embedded in shot data - no API call needed!
if (shot.value?.task_details) {
tasks.value = shot.value.task_details.map(taskInfo => ({
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// ... other fields
}))
} else {
tasks.value = []
}
}
```
### ✅ AssetDetailPanel Component
**VERIFIED:** Component uses embedded task data from asset response
**Evidence from `frontend/src/components/asset/AssetDetailPanel.vue`:**
```typescript
// Lines 85-100: Task data extraction from embedded asset data
const tasks = computed(() => {
if (!asset.value?.task_details) return []
return asset.value.task_details.map((taskInfo: TaskStatusInfo) => {
return {
id: taskInfo.task_id || 0,
name: formatTaskType(taskInfo.task_type),
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id
}
})
})
```
### ✅ TaskBrowser Component
**VERIFIED:** Component extracts tasks from embedded shot and asset data
**Evidence from `frontend/src/components/task/TaskBrowser.vue`:**
```typescript
// Lines 200-240: Optimized task fetching
const fetchTasks = async () => {
// Get both shots and assets with embedded task data (two optimized backend calls)
const [shots, assets] = await Promise.all([
shotService.getShots({ projectId: props.projectId }),
assetService.getAssets(props.projectId)
])
// Extract tasks from embedded data - no separate task API calls needed!
const shotTasks = shots.flatMap(shot =>
(shot.task_details || []).map(taskDetail => ({ /* ... */ }))
)
const assetTasks = assets.flatMap(asset =>
(asset.task_details || []).map(taskDetail => ({ /* ... */ }))
)
tasks.value = [...shotTasks, ...assetTasks]
}
```
### ✅ TasksStore
**VERIFIED:** Store uses embedded data from shots and assets
**Evidence from `frontend/src/stores/tasks.ts`:**
```typescript
// Lines 40-70: Optimized task fetching
async function fetchTasks(filters?: { projectId?: number }) {
if (filters?.projectId) {
// Use optimized approach: get both shots and assets with embedded task data
const [shots, assets] = await Promise.all([
shotService.getShotsByProject(filters.projectId),
assetService.getAssets(filters.projectId)
])
// Extract tasks from embedded data in shots and assets
const shotTasks = extractTasksFromShots(shots)
const assetTasks = extractTasksFromAssets(assets)
// Combine all tasks
tasks.value = [...shotTasks, ...assetTasks]
}
}
```
## Database Optimization Verification
### ✅ Single Query Operations
**VERIFIED:** Both shot and asset endpoints use optimized JOIN queries
**Shot Endpoint Evidence:**
```python
# Lines 220-235: Single query with JOIN
shots_with_tasks = (
base_query
.outerjoin(Task, (Task.shot_id == Shot.id) & (Task.deleted_at.is_(None)))
.options(joinedload(Shot.episode).joinedload(Episode.project))
.add_columns(
Task.id.label('task_id'),
Task.task_type,
Task.status.label('task_status'),
Task.assigned_user_id
)
.offset(skip)
.limit(limit)
.all()
)
```
**Asset Endpoint Evidence:**
```python
# Lines 220-235: Single query with JOIN
assets_with_tasks = (
base_query
.outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None)))
.options(joinedload(Asset.project))
.add_columns(
Task.id.label('task_id'),
Task.task_type,
Task.status.label('task_status'),
Task.assigned_user_id
)
.offset(skip)
.limit(limit)
.all()
)
```
## Performance Benefits Achieved
### 🚀 API Call Reduction
- **Before:** N+1 queries (1 for shots/assets + N for individual task queries)
- **After:** Single JOIN query per endpoint
- **Frontend:** Components use embedded data instead of separate task API calls
### 🚀 Network Request Optimization
- **ShotDetailPanel:** Eliminated redundant `taskService.getTasks({ shotId })` calls
- **TaskBrowser:** Reduced from 3 API calls to 2 (shots + assets, no separate tasks call)
- **TasksStore:** Uses embedded data extraction instead of separate task queries
### 🚀 Data Consistency
- Task status and details are fetched atomically with parent entities
- No risk of stale data between separate API calls
- Real-time consistency between shots/assets and their tasks
## Test Coverage
### Manual Testing Recommendations
1. **API Response Validation:**
```bash
# Test shot endpoint
curl "http://localhost:8000/api/shots/?project_id=1" | jq '.[] | {id, name, task_status, task_details}'
# Test asset endpoint
curl "http://localhost:8000/api/assets/?project_id=1" | jq '.[] | {id, name, task_status, task_details}'
```
2. **Frontend Component Testing:**
- Open ShotDetailPanel and verify tasks load without additional network requests
- Open TaskBrowser and verify task data is extracted from embedded shot/asset data
- Check browser Network tab to confirm reduced API calls
3. **Performance Testing:**
- Compare page load times before/after optimization
- Monitor database query count in backend logs
- Verify sub-500ms response times for 100+ shots/assets
## Conclusion
✅ **TASK 11 COMPLETED SUCCESSFULLY**
All requirements have been verified through code analysis:
1. **✅ Requirement 4.1:** Shot endpoints return embedded `task_status` field with all associated task information
2. **✅ Requirement 4.2:** Asset endpoints return embedded `task_status` field with all associated task information
3. **✅ Requirement 4.3:** Task status data includes task type, current status, assignee, and task ID information
**Frontend components successfully consume the optimized data format:**
- ShotDetailPanel uses embedded task data
- AssetDetailPanel uses embedded task data
- TaskBrowser extracts tasks from embedded data
- TasksStore leverages optimized data fetching
**Performance optimizations achieved:**
- Single JOIN queries replace N+1 patterns
- Frontend components eliminate redundant API calls
- Network requests reduced significantly
- Data consistency improved through atomic fetching
The response format validation is complete and all optimized endpoints are functioning as designed.
@@ -34,7 +34,7 @@
</div>
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
<ActivityIcon class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No activity yet</p>
</div>
@@ -1,209 +0,0 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Activity Timeline</h3>
<Button
variant="ghost"
size="icon"
@click="loadActivities"
:disabled="loading"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
</div>
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
Loading timeline...
</div>
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
<Clock class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No activity recorded</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
<!-- Timeline items -->
<div class="space-y-6">
<div
v-for="activity in activities"
:key="activity.id"
class="relative pl-10"
>
<!-- Timeline dot -->
<div
class="absolute left-2.5 w-3 h-3 rounded-full border-2 border-background"
:class="getTimelineDotColor(activity.type)"
/>
<!-- Activity content -->
<div class="bg-card border rounded-lg p-4">
<div class="flex items-start gap-3">
<component
:is="getActivityIcon(activity.type)"
class="h-5 w-5 mt-0.5 flex-shrink-0"
:class="getActivityColor(activity.type)"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{{ getActivityTitle(activity.type) }}</p>
<p class="text-sm text-muted-foreground mt-1">{{ activity.description }}</p>
<!-- Metadata display -->
<div v-if="activity.activity_metadata" class="mt-2 text-xs text-muted-foreground">
<div v-if="activity.activity_metadata.old_status && activity.activity_metadata.new_status">
<Badge variant="outline" class="mr-2">{{ activity.activity_metadata.old_status }}</Badge>
<Badge variant="outline" class="ml-2">{{ activity.activity_metadata.new_status }}</Badge>
</div>
<div v-if="activity.activity_metadata.version">
Version {{ activity.activity_metadata.version }}
</div>
<div v-if="activity.activity_metadata.decision">
Decision: <Badge :variant="activity.activity_metadata.decision === 'approved' ? 'default' : 'destructive'">
{{ activity.activity_metadata.decision }}
</Badge>
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<Avatar class="h-5 w-5">
<AvatarFallback class="text-xs">
{{ getInitials(activity.user) }}
</AvatarFallback>
</Avatar>
<span class="text-xs text-muted-foreground">
{{ activity.user.first_name }} {{ activity.user.last_name }}
</span>
<span class="text-xs text-muted-foreground"></span>
<span class="text-xs text-muted-foreground">
{{ formatTime(activity.created_at) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import {
Clock,
FileText,
CheckCircle,
UserPlus,
MessageSquare,
RefreshCw
} from 'lucide-vue-next'
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
import * as activityService from '@/services/activity'
interface Props {
taskId: number
}
const props = defineProps<Props>()
const activities = ref<Activity[]>([])
const loading = ref(false)
onMounted(() => {
loadActivities()
})
watch(() => props.taskId, () => {
loadActivities()
})
async function loadActivities() {
loading.value = true
try {
activities.value = await activityService.getTaskActivities(props.taskId)
} catch (error) {
console.error('Failed to load task activities:', error)
} finally {
loading.value = false
}
}
function getInitials(user: UserInfo): string {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase()
}
function getActivityIcon(type: ActivityType) {
const iconMap: Record<string, any> = {
task_created: FileText,
task_updated: FileText,
task_assigned: UserPlus,
task_status_changed: CheckCircle,
submission_created: FileText,
submission_reviewed: CheckCircle,
comment_added: MessageSquare
}
return iconMap[type] || FileText
}
function getActivityColor(type: ActivityType): string {
const colorMap: Record<string, string> = {
task_created: 'text-blue-500',
task_updated: 'text-blue-500',
task_assigned: 'text-purple-500',
task_status_changed: 'text-green-500',
submission_created: 'text-orange-500',
submission_reviewed: 'text-green-500',
comment_added: 'text-gray-500'
}
return colorMap[type] || 'text-gray-500'
}
function getTimelineDotColor(type: ActivityType): string {
const colorMap: Record<string, string> = {
task_created: 'bg-blue-500',
task_updated: 'bg-blue-500',
task_assigned: 'bg-purple-500',
task_status_changed: 'bg-green-500',
submission_created: 'bg-orange-500',
submission_reviewed: 'bg-green-500',
comment_added: 'bg-gray-500'
}
return colorMap[type] || 'bg-gray-500'
}
function getActivityTitle(type: ActivityType): string {
const titleMap: Record<string, string> = {
task_created: 'Task Created',
task_updated: 'Task Updated',
task_assigned: 'Task Assigned',
task_status_changed: 'Status Changed',
submission_created: 'Work Submitted',
submission_reviewed: 'Submission Reviewed',
comment_added: 'Comment Added'
}
return titleMap[type] || 'Activity'
}
function formatTime(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
</script>
+108 -110
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"
@@ -16,6 +16,7 @@
:selected-asset="selectedAsset"
:is-detail-panel-enabled="isDetailPanelEnabled"
:show-thumbnails="showThumbnails"
:is-columns-locked="lockColumns"
@update:view-mode="viewMode = $event"
@update:category-filter="handleCategoryFilterChange"
@update:search="searchQuery = $event"
@@ -23,6 +24,7 @@
@update:show-thumbnails="handleThumbnailToggle"
@task-status-filter-changed="handleTaskStatusFilter"
@toggle-detail-panel="toggleDetailPanelEnabled"
@toggle-column-lock="toggleColumnLock"
@create-asset="showCreateDialog = true"
/>
<div v-else class="flex items-center justify-center py-4">
@@ -34,7 +36,7 @@
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
@@ -44,7 +46,7 @@
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12">
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
@@ -59,7 +61,7 @@
v-else-if="
filteredAssets.length === 0 && !searchQuery && !selectedCategory
"
class="text-center py-12"
class="text-center py-12 px-4 sm:px-6"
>
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
@@ -73,7 +75,7 @@
</div>
<!-- No Results State -->
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
<p class="text-muted-foreground mb-4">
@@ -83,11 +85,11 @@
</div>
<!-- Assets Grid/List -->
<div v-else>
<div v-else class="flex-1 min-h-0">
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
>
<AssetCard
v-for="asset in filteredAssets"
@@ -109,12 +111,13 @@
:sorting="sorting"
:column-visibility="columnVisibility"
:all-task-types="allTaskTypes"
:lock-columns="lockColumns"
@update:sorting="sorting = $event"
@update:column-visibility="handleColumnVisibilityChange"
@update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick"
/>
<div v-else class="flex items-center justify-center py-12">
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading table...</span>
@@ -161,73 +164,39 @@
</Dialog>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
action cannot be undone and will remove all associated tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
@click="handleDeleteAsset"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Asset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AssetDeleteConfirmDialog
v-if="deletionInfo && assetToDelete"
:open="showDeleteDialog"
:asset-id="assetToDelete.id"
:asset-name="deletionInfo.asset_name"
@update:open="showDeleteDialog = $event"
@confirm-delete="handleDeleteAsset"
/>
</div>
<!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
<!-- Asset Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedAsset)"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</div>
</Transition>
<!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<AssetDetailPanel
v-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</SheetContent>
</Sheet>
</DetailPanelOverlay>
</div>
</template>
@@ -251,20 +220,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
import AssetsDataTable from "./AssetsDataTable.vue";
import AssetTableToolbar from "./AssetTableToolbar.vue";
import { createAssetColumns, type AssetColumnMeta } from "./columns";
@@ -273,11 +234,13 @@ import { useAuthStore } from "@/stores/auth";
import { useTaskStatusesStore } from "@/stores/taskStatuses";
import { useDetailPanel } from "@/composables/useDetailPanel";
import {
assetService,
AssetCategory,
TaskStatus,
type Asset,
type AssetCreate,
type AssetUpdate,
type AssetDeletionInfo,
} from "@/services/asset";
import { useToast } from "@/components/ui/toast/use-toast";
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
@@ -385,6 +348,9 @@ const showDeleteDialog = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const assetToDelete = ref<Asset | null>(null);
const deletionInfo = ref<AssetDeletionInfo | null>(null);
const taskStatusFilter = ref('')
// Thumbnail display state - with session storage
@@ -392,6 +358,13 @@ const showThumbnails = ref(
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
);
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
const toggleColumnLock = () => {
lockColumns.value = !lockColumns.value;
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
};
// Computed properties
const assets = computed(() => assetsStore.assets);
const isLoading = computed(() => assetsStore.isLoading);
@@ -451,6 +424,7 @@ const assetColumns = computed(() => {
onDelete: deleteAsset,
onViewTasks: viewAssetTasks,
onTaskStatusUpdated: handleTaskStatusUpdate,
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
onBulkTaskStatusChange: handleBulkTaskStatusChange,
getSelectedCount: () => selectedCount.value,
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
@@ -563,14 +537,25 @@ const editAsset = (asset: Asset) => {
showEditDialog.value = true;
};
const deleteAsset = (asset: Asset) => {
selectedAsset.value = asset;
const deleteAsset = async (asset: Asset) => {
// Don't set selectedAsset here as it opens the detail panel
assetToDelete.value = asset;
try {
deletionInfo.value = await assetService.getAssetDeletionInfo(asset.id);
showDeleteDialog.value = true;
} catch (err) {
toast({
title: "Failed to get asset information",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
}
};
const viewAssetTasks = (asset: Asset) => {
// TODO: Navigate to asset tasks view
console.log("View tasks for asset:", asset.name);
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
selectAsset(asset);
};
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
@@ -619,15 +604,21 @@ const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
};
const handleDeleteAsset = async () => {
if (!selectedAsset.value) return;
if (!assetToDelete.value) return;
try {
await assetsStore.deleteAsset(selectedAsset.value.id);
await assetsStore.deleteAsset(assetToDelete.value.id);
showDeleteDialog.value = false;
selectedAsset.value = null;
assetToDelete.value = null;
const taskCount = deletionInfo.value?.task_count || 0;
deletionInfo.value = null;
toast({
title: "Asset deleted",
description: "Asset has been deleted successfully.",
description: taskCount > 0
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
: "Asset has been deleted successfully.",
});
} catch (err) {
toast({
@@ -660,6 +651,24 @@ const handleTaskStatusUpdate = async (
}
};
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
// Update local state instead of reloading all assets
const asset = assetsStore.assets.find((a) => a.id === assetId);
if (asset && asset.task_details) {
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
if (taskDetail) {
taskDetail.assigned_user_id = userId ?? undefined;
}
}
toast({
title: "Task assignment updated",
description: userId
? `${formatTaskType(taskType)} task assigned successfully.`
: `${formatTaskType(taskType)} task unassigned successfully.`,
});
};
const clearFilters = () => {
selectedCategory.value = "all";
searchQuery.value = "";
@@ -677,30 +686,19 @@ const formatTaskType = (taskType: string) => {
};
// Detail panel event handlers
const handleCreateTask = () => {
// TODO: Navigate to task creation for this asset
console.log('Create task for asset:', selectedAsset.value?.name);
const selectedTaskId = ref<number | null>(null);
const selectedTaskTab = ref<string>('infos');
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id;
selectedTaskTab.value = tab || 'infos';
};
const handleSelectTask = (task: any) => {
// TODO: Open task detail panel
console.log('Select task:', task);
};
const handleCreateNote = () => {
// TODO: Open note creation dialog
console.log('Create note for asset:', selectedAsset.value?.name);
};
const handleUploadReference = () => {
// TODO: Open reference upload dialog
console.log('Upload reference for asset:', selectedAsset.value?.name);
};
const handlePublishVersion = () => {
// TODO: Open version publish dialog
console.log('Publish version for asset:', selectedAsset.value?.name);
};
// Reset the task sub-panel whenever the asset selection changes (including close)
watch(selectedAsset, () => {
selectedTaskId.value = null;
selectedTaskTab.value = 'infos';
});
// Load custom task types from project
const loadCustomTaskTypes = async () => {
@@ -21,13 +21,11 @@
</div>
<!-- Error State -->
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="h-4 w-4 text-destructive" />
<span class="font-medium text-destructive">Failed to load deletion information</span>
</div>
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
</div>
<Alert v-else-if="loadError" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertTitle>Failed to load deletion information</AlertTitle>
<AlertDescription>{{ loadError }}</AlertDescription>
</Alert>
<!-- Deletion Information -->
<div v-else-if="deletionInfo" class="space-y-4">
@@ -73,15 +71,13 @@
</div>
<!-- Affected Users -->
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4">
<div class="flex items-center gap-2 mb-3">
<Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
<Users class="h-4 w-4 text-orange-600" />
<span class="font-medium text-orange-800">
<AlertTitle class="text-orange-800">
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
</span>
</div>
<p class="text-sm text-orange-700 mb-3">
</AlertTitle>
<AlertDescription class="text-orange-700">
<p class="mb-3">
The following users have work associated with this asset that will be marked as deleted:
</p>
@@ -93,7 +89,7 @@
>
<div class="flex-1">
<div class="font-medium text-sm">{{ user.name }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} {{ user.role }}</div>
</div>
<div class="text-xs text-muted-foreground text-right">
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
@@ -105,27 +101,26 @@
</div>
</div>
</div>
</div>
</AlertDescription>
</Alert>
<!-- No affected users -->
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4">
<div class="flex items-center gap-2">
<Alert v-else variant="default" class="border-green-200 bg-green-50">
<CheckCircle class="h-4 w-4 text-green-600" />
<span class="text-sm text-green-800">No users will be affected by this deletion.</span>
</div>
</div>
<AlertDescription class="text-green-800">
No users will be affected by this deletion.
</AlertDescription>
</Alert>
<!-- Data Preservation Notice -->
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center gap-2 mb-2">
<Alert variant="default" class="border-blue-200 bg-blue-50">
<Shield class="h-4 w-4 text-blue-600" />
<span class="font-medium text-blue-800">Data Preservation</span>
</div>
<p class="text-sm text-blue-700">
<AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
<AlertDescription class="text-blue-700">
All data will be preserved in the database and can be recovered by administrators.
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
</p>
</div>
</AlertDescription>
</Alert>
<!-- Confirmation input -->
<div class="space-y-2">
@@ -189,6 +184,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/ui/alert'
import { assetService, type AssetDeletionInfo } from '@/services/asset'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -275,7 +275,7 @@ watch(() => props.open, (newOpen) => {
deletionInfo.value = null
loadError.value = null
}
})
}, { immediate: true })
const handleDelete = async () => {
if (!isConfirmed.value) return
@@ -1,64 +1,48 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading asset details...</span>
</div>
</div>
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadAssetDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<DetailPanelError
v-else-if="error"
title="Failed to load asset"
:message="error"
@retry="loadAssetDetails"
/>
<!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': asset.deleted_at }">{{ asset.name }}</h2>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="references">
References
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="notes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ notes.length > 99 ? '99+' : notes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ references.length }}
</Badge>
</TabsTrigger>
@@ -117,7 +101,110 @@
<!-- Task Status & Assignees -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3>
<div class="flex items-center gap-1">
<!-- Add Task -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
<Plus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
All task types already added
</div>
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="type in availableTaskTypes"
:key="type"
variant="ghost"
size="sm"
class="justify-start"
:disabled="isCreatingTask"
@click="handleAddTask(type)"
>
{{ formatTaskType(type) }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Add Note (picks a task, opens its Notes tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
<MessageSquarePlus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
<Paperclip class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'attachments')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Publish Version (picks a task, opens its Submissions tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'submissions')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- Loading Tasks -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
@@ -168,7 +255,13 @@
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
<EntityNotes
:key="assetId"
:tasks="tasks"
:notes="notes"
:submissions="submissions"
@notes-updated="loadNotes"
/>
</TabsContent>
<!-- References Tab -->
@@ -183,18 +276,24 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, X
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import AssetNotes from './AssetNotes.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useDepartmentsStore } from '@/stores/departments'
interface Task {
id: number
@@ -208,16 +307,13 @@ interface Task {
interface Props {
projectId: number
assetId: number
allTaskTypes: string[]
}
interface Emits {
(e: 'edit', asset: Asset): void
(e: 'delete', asset: Asset): void
(e: 'create-task'): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'upload-reference'): void
(e: 'publish-version'): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'close'): void
}
@@ -226,13 +322,16 @@ const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userStore = useUserStore()
const departmentsStore = useDepartmentsStore()
// Reactive state
const asset = ref<Asset | null>(null)
const notes = ref<any[]>([])
const notes = ref<ProductionNote[]>([])
const submissions = ref<Submission[]>([])
const references = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
// Computed properties
const tasks = computed(() => {
@@ -266,6 +365,17 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
// Task types offered in "Add Task" include the flat asset task type list
// plus every task type owned by an asset department (e.g. Modeling's own
// task types), deduped against types the asset already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
const taskStatusCounts = computed(() => {
const counts = {
not_started: 0,
@@ -292,6 +402,7 @@ const loadAssetDetails = async () => {
isLoading.value = true
error.value = null
asset.value = await assetService.getAsset(props.assetId)
departmentsStore.fetchProjectDepartments(props.projectId)
// Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) {
@@ -310,28 +421,18 @@ const loadAssetDetails = async () => {
}
const loadNotes = async () => {
if (tasks.value.length === 0) {
notes.value = []
submissions.value = []
return
}
try {
// Load notes from all tasks associated with this asset
const { taskService } = await import('@/services/task')
const allNotes: any[] = []
for (const task of tasks.value) {
if (task.id) {
const taskNotes = await taskService.getTaskNotes(task.id)
// Add task info to each note for context
const notesWithContext = taskNotes.map(note => ({
...note,
task_name: task.name,
task_type: task.task_type
}))
allNotes.push(...notesWithContext)
}
}
// Sort by date (newest first)
notes.value = allNotes.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
notes.value = notesByTask.flat()
submissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load notes:', err)
}
@@ -365,6 +466,18 @@ const loadReferences = async () => {
}
}
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createAssetTask(props.assetId, taskType)
await loadAssetDetails()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatCategory = (category: string) => {
return category.charAt(0).toUpperCase() + category.slice(1)
}
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-col h-full">
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet for this asset's tasks.</p>
</div>
<div
v-for="note in notes"
:key="note.id"
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
>
<!-- Note Header -->
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium">{{ note.author_name }}</span>
<Badge variant="outline" class="text-xs">
{{ formatTaskType(note.task_type) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ note.task_name }} {{ formatDate(note.created_at) }}
</p>
</div>
</div>
<!-- Note Content -->
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MessageSquarePlus } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
const props = defineProps<{
assetId: number
notes: any[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
@@ -43,8 +43,8 @@
</div>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0 flex-shrink-0"
size="icon-sm"
class="flex-shrink-0"
@click="downloadFile(reference)"
>
<Download class="h-4 w-4" />
@@ -5,26 +5,11 @@
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
</div>
<SegmentedToggle
:options="viewModeOptions"
:model-value="viewMode"
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list')"
/>
<!-- Category Filter -->
<Popover>
@@ -47,139 +32,44 @@
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:category-filter', 'all')"
:model-value="categoryFilter === 'all'"
@update:model-value="$emit('update:category-filter', 'all')"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Categories</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="category in categories"
:key="category.value"
:value="category.value"
@select="$emit('update:category-filter', category.value)"
:model-value="categoryFilter === category.value"
@update:model-value="$emit('update:category-filter', category.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === category.value
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<component :is="category.icon" class="h-4 w-4 mr-2" />
<span>{{ category.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Thumbnail Toggle Button -->
<Button
variant="outline"
size="sm"
@click="$emit('update:show-thumbnails', !showThumbnails)"
class="h-8"
>
<ImageIcon v-if="!showThumbnails" class="h-4 w-4 mr-2" />
<ImageOff v-else class="h-4 w-4 mr-2" />
{{ showThumbnails ? 'Hide' : 'Show' }} Thumbnails
</Button>
<!-- Task Status Filter (only for list view) -->
<TaskStatusFilter
v-if="viewMode === 'list'"
:all-task-types="allTaskTypes"
:project-id="projectId"
@filter-changed="$emit('task-status-filter-changed', $event)"
/>
<!-- Column Visibility Control (only for list view) -->
<Popover v-if="viewMode === 'list'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ColumnToggleList
v-if="viewMode === 'list'"
:columns="columnToggleOptions"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
<!-- Task Columns Toggle Button (only for list view) -->
<Button
@@ -195,32 +85,27 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
<DetailPanelToggleButton
v-if="viewMode === 'list'"
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for list view) -->
<Button
v-if="viewMode === 'list'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
<Unlock v-else class="h-4 w-4" />
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -236,8 +121,19 @@
/>
</div>
<!-- Thumbnail Toggle -->
<Button
variant="outline"
size="icon-sm"
@click="$emit('update:show-thumbnails', !showThumbnails)"
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
>
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
<ImageOff v-else class="h-4 w-4" />
</Button>
<!-- Create Asset Button -->
<Button @click="$emit('create-asset')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-asset')" size="icon-sm" title="Create Asset">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -249,7 +145,8 @@
import { computed } from 'vue'
import {
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
ListTodo, ListX,
Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -264,11 +161,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusFilter from './TaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Asset, AssetCategory } from '@/services/asset'
@@ -289,6 +190,7 @@ interface Props {
selectedAsset: Asset | null
isDetailPanelEnabled: boolean
showThumbnails: boolean
isColumnsLocked: boolean
}
const props = defineProps<Props>()
@@ -301,6 +203,7 @@ const emit = defineEmits<{
'update:show-thumbnails': [value: boolean]
'task-status-filter-changed': [value: string]
'toggle-detail-panel': []
'toggle-column-lock': []
'create-asset': []
}>()
@@ -327,9 +230,19 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -338,21 +251,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
@@ -1,8 +1,106 @@
<template>
<div class="space-y-4">
<div class="rounded-md border">
<Table>
<TableHeader>
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns">
<div v-if="hasRows" class="flex h-full">
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm">
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
:key="header.id"
:style="frozenWidth(header.column.id)"
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in frozenCells(row)"
:key="cell.id"
:style="frozenWidth(cell.column.id)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm">
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
:key="header.id"
:class="[
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
header.column.id === 'actions' ? 'w-12' : '',
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
]"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in movableCells(row)"
:key="cell.id"
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
</div>
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
No results.
</div>
</template>
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -67,13 +165,15 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import {
FlexRender,
getCoreRowModel,
getSortedRowModel,
useVueTable,
type ColumnDef,
type HeaderGroup,
type Row,
type SortingState,
type VisibilityState,
} from '@tanstack/vue-table'
@@ -93,10 +193,51 @@ interface Props {
sorting: SortingState
columnVisibility: VisibilityState
allTaskTypes: string[]
lockColumns?: boolean
}
const props = defineProps<Props>()
// Frozen (locked) columns and their fixed widths (px), in column order.
const FROZEN: Record<string, number> = {
select: 48,
thumbnail: 96,
name: 200,
}
const isFrozen = (id: string) => id in FROZEN
const frozenWidth = (id: string) => {
const w = FROZEN[id]
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
}
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
// respect column visibility; frozen ones are always shown.
const frozenHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
const movableHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
const frozenCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
const movableCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
const hasRows = computed(() => table.getRowModel().rows.length > 0)
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
const leftPane = ref<HTMLElement>()
const rightPane = ref<HTMLElement>()
let syncing = false
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
if (syncing || !from || !to) return
syncing = true
to.scrollTop = from.scrollTop
requestAnimationFrame(() => { syncing = false })
}
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
const emit = defineEmits<{
'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState]
@@ -277,4 +418,13 @@ watch(
-ms-user-select: text;
user-select: text;
}
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
</style>
@@ -1,256 +0,0 @@
<template>
<div class="flex items-center gap-2">
<!-- Global Task Columns Toggle Button -->
<Button
variant="outline"
size="sm"
@click="toggleAllTaskColumns"
class="h-9"
>
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
<ListX v-else class="h-4 w-4 mr-2" />
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
</Button>
<!-- Column Visibility Dropdown -->
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Toggle columns">
<div class="flex items-center gap-2">
<Columns class="h-4 w-4" />
<span>Columns</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="toggle">Toggle Columns</SelectItem>
<SelectGroup>
<SelectLabel>Basic Columns</SelectLabel>
<SelectItem value="thumbnail" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.thumbnail"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('thumbnail', val)"
/>
<span>Thumbnail</span>
</div>
</SelectItem>
<SelectItem value="name" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.name"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('name', val)"
/>
<span>Name</span>
</div>
</SelectItem>
<SelectItem value="category" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.category"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('category', val)"
/>
<span>Category</span>
</div>
</SelectItem>
<SelectItem value="status" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.status"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('status', val)"
/>
<span>Status</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Task Status Columns</SelectLabel>
<!-- Standard Task Types -->
<SelectItem value="modeling" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.modeling"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('modeling', val)"
/>
<span>Modeling</span>
</div>
</SelectItem>
<SelectItem value="surfacing" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.surfacing"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('surfacing', val)"
/>
<span>Surfacing</span>
</div>
</SelectItem>
<SelectItem value="rigging" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.rigging"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('rigging', val)"
/>
<span>Rigging</span>
</div>
</SelectItem>
<!-- Custom Task Types -->
<SelectItem
v-for="customType in customTaskTypes"
:key="customType"
:value="customType"
@click.stop
>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns[customType]"
@update:checked="(val: boolean | 'indeterminate') => updateColumn(customType, val)"
/>
<span>{{ formatTaskType(customType) }}</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Other Columns</SelectLabel>
<SelectItem value="description" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.description"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('description', val)"
/>
<span>Description</span>
</div>
</SelectItem>
<SelectItem value="updatedAt" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.updatedAt"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('updatedAt', val)"
/>
<span>Updated</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Columns, ListTodo, ListX } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface Props {
visibleColumns: Record<string, boolean>;
projectId?: number;
}
interface Emits {
(e: "update:visibleColumns", columns: Record<string, boolean>): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const selectedColumn = ref('toggle')
const customTaskTypes = ref<string[]>([])
const savedTaskColumnStates = ref<Record<string, boolean>>({})
// Standard task types
const standardTaskTypes = ['modeling', 'surfacing', 'rigging']
// All task types (standard + custom)
const allTaskTypes = computed(() => [...standardTaskTypes, ...customTaskTypes.value])
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
return allTaskTypes.value.every(taskType => props.visibleColumns[taskType])
})
// Load custom task types from project
const loadCustomTaskTypes = async () => {
if (!props.projectId) return
try {
const { projectService } = await import('@/services/project')
const project = await projectService.getProject(props.projectId)
customTaskTypes.value = project.custom_asset_task_types || []
// Initialize visibility for custom task types if not already set
const newColumns = { ...props.visibleColumns }
let hasChanges = false
for (const customType of customTaskTypes.value) {
if (!(customType in newColumns)) {
newColumns[customType] = true // Show custom types by default
hasChanges = true
}
}
if (hasChanges) {
emit("update:visibleColumns", newColumns)
}
} catch (error) {
console.warn('Could not load custom task types:', error)
// Continue without custom task types
}
}
// Toggle all task columns show/hide
const toggleAllTaskColumns = () => {
const newColumns = { ...props.visibleColumns }
if (allTaskColumnsVisible.value) {
// Hide all task columns but save their states
savedTaskColumnStates.value = {}
for (const taskType of allTaskTypes.value) {
savedTaskColumnStates.value[taskType] = newColumns[taskType]
newColumns[taskType] = false
}
} else {
// Restore saved states or show all
for (const taskType of allTaskTypes.value) {
if (taskType in savedTaskColumnStates.value) {
newColumns[taskType] = savedTaskColumnStates.value[taskType]
} else {
newColumns[taskType] = true
}
}
savedTaskColumnStates.value = {}
}
emit("update:visibleColumns", newColumns)
}
const handleColumnToggle = () => {
// Reset selection after interaction
selectedColumn.value = 'toggle'
}
const updateColumn = (column: string, checked: boolean | 'indeterminate') => {
const newColumns = { ...props.visibleColumns }
newColumns[column] = checked === true
emit('update:visibleColumns', newColumns)
}
const formatTaskType = (taskType: string) => {
return taskType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
onMounted(() => {
loadCustomTaskTypes()
})
</script>
@@ -1,6 +1,5 @@
<template>
<div class="relative"
>
<div class="relative flex items-center gap-1">
<Select
:model-value="currentStatusId"
@update:model-value="handleStatusChange"
@@ -12,12 +11,7 @@
>
<SelectValue
:model-value="currentStatusId"
>
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
<!-- console.log(currentStatusObject) -->
<!-- currentStatusObject -->
</SelectValue>
/>
</SelectTrigger>
<SelectContent>
<SelectItem
@@ -38,6 +32,112 @@
</SelectContent>
</Select>
<!-- User Assignment Button -->
<div @click.stop>
<Popover>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-accent relative"
:disabled="isUpdating"
@click.stop="ensureMembersLoaded"
>
<Avatar class="h-4 w-4" v-if="assignedUser">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<User class="h-3 w-3" v-else />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
<div class="space-y-2">
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
<!-- Current Assignment Display with X button -->
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground">Current</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
@click.stop="handleAssignUser(null)"
:disabled="isAssigning"
>
<X class="h-3 w-3" />
</Button>
</div>
<!-- Search Input -->
<div class="relative">
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search members..."
class="pl-7 h-8 text-xs"
/>
</div>
<!-- Loading state -->
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2 text-sm">Loading members...</span>
</div>
<!-- Error state -->
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
No project members found
<Button
variant="outline"
size="sm"
class="mt-2"
@click="loadProjectMembers"
>
Retry
</Button>
</div>
<!-- Content when members are loaded -->
<template v-else>
<!-- Project members list -->
<div class="max-h-64 overflow-y-auto">
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
No matching members found
</div>
<Button
v-else
v-for="member in filteredProjectMembers"
:key="member.user_id"
variant="ghost"
size="sm"
class="w-full justify-start h-10"
@click="handleAssignUser(member.user_id)"
:disabled="isAssigning"
>
<Avatar class="h-8 w-8 mr-2">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
</div>
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Loading indicator -->
<div
v-if="isUpdating || isLoadingStatuses"
@@ -57,11 +157,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface StatusOption {
id: string
@@ -76,19 +188,48 @@ interface Props {
status: TaskStatus | string
taskId?: number | null
projectId: number
assignedUserId?: number | null
}
interface Emits {
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
(e: 'assignment-updated', assetId: number, taskType: string, userId: number | null): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getAvatarUrl } = useAvatarUrl()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
const filteredProjectMembers = computed(() => {
if (!searchQuery.value.trim()) {
return projectMembers.value
}
const query = searchQuery.value.toLowerCase().trim()
return projectMembers.value.filter(member => {
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
const departmentRole = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || departmentRole.includes(query)
})
})
// Get assigned user info
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
// Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
@@ -144,6 +285,18 @@ const formatStatusName = (status: string): string => {
}
}
// Format department role for display
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
// Get user initials
const getUserInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
// Fetch custom statuses for the project using store
const fetchStatuses = async () => {
if (!props.projectId) return
@@ -155,6 +308,57 @@ const fetchStatuses = async () => {
}
}
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
try {
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
if (projectMembers.value.length === 0) {
loadProjectMembers()
}
}
const handleAssignUser = async (userId: number | null) => {
isAssigning.value = true
try {
let taskId = props.taskId
// If no task exists, create one first
if (!taskId) {
const newTask = await taskService.createAssetTask(props.assetId, props.taskType)
taskId = newTask.task_id
}
// Assign or unassign the task
if (taskId) {
if (userId) {
// Use the assignment endpoint for assigning to a user
await taskService.assignTask(taskId, userId)
} else {
// Use the update endpoint for unassignment (set assigned_user_id to 0)
await taskService.updateTask(taskId, { assigned_user_id: 0 })
}
emit('assignment-updated', props.assetId, props.taskType, userId)
}
// Close popover by simulating click outside after assignment
setTimeout(() => {
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}, 100)
} catch (error) {
console.error('Failed to assign task:', error)
} finally {
isAssigning.value = false
}
}
const handleStatusChange = async (newStatusId: any) => {
if (!newStatusId || newStatusId === currentStatusId.value) return
@@ -189,10 +393,13 @@ const handleStatusChange = async (newStatusId: any) => {
// Fetch statuses on mount
onMounted(() => {
fetchStatuses()
// Preload project members to ensure they're available when needed
loadProjectMembers()
})
// Refetch statuses when projectId changes
// Refetch statuses and members when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
loadProjectMembers()
})
</script>
@@ -1,175 +0,0 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedFilter" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[200px]">
<SelectValue placeholder="Filter by task status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tasks</SelectItem>
<SelectGroup>
<SelectLabel>Modeling</SelectLabel>
<SelectItem
v-for="status in allStatuses"
:key="`modeling:${status.id}`"
:value="`modeling:${status.id}`"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status.id as any" />
<span>Modeling - {{ status.name }}</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Surfacing</SelectLabel>
<SelectItem
v-for="status in allStatuses"
:key="`surfacing:${status.id}`"
:value="`surfacing:${status.id}`"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
<span>Surfacing - {{ status.name }}</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Rigging</SelectLabel>
<SelectItem
v-for="status in allStatuses"
:key="`rigging:${status.id}`"
:value="`rigging:${status.id}`"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
<span>Rigging - {{ status.name }}</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
v-if="selectedFilter && selectedFilter !== 'all'"
variant="ghost"
size="sm"
@click="clearFilter"
class="h-8 px-2"
>
<X class="h-4 w-4" />
</Button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
projectId?: number
}
interface Emits {
(e: 'filter-changed', filter: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const selectedFilter = ref('all')
// Get loading state from store
const isLoading = computed(() => {
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
})
// System status options (fallback if no project ID)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
]
// Combine system and custom statuses
const allStatuses = computed(() => {
if (!props.projectId) {
return defaultStatusOptions
}
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) {
return defaultStatusOptions
}
// Convert system statuses to the format expected by TaskStatusBadge
const systemStatusList = statusData.system_statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: status.is_system
}))
// Convert custom statuses to the format expected by TaskStatusBadge
const customStatusList = statusData.statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: false
}))
return [...systemStatusList, ...customStatusList]
})
// Load custom statuses when component mounts or projectId changes
const loadStatuses = async () => {
if (!props.projectId) {
return
}
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to load task statuses:', error)
}
}
onMounted(() => {
loadStatuses()
})
watch(() => props.projectId, () => {
loadStatuses()
})
const handleFilterChange = (filter: any) => {
if (!filter) return
const filterStr = String(filter)
selectedFilter.value = filterStr
// Convert "all" to empty string for the API
const apiFilter = filterStr === 'all' ? '' : filterStr
emit('filter-changed', apiFilter)
}
const clearFilter = () => {
selectedFilter.value = 'all'
emit('filter-changed', '')
}
</script>
+9 -3
View File
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
onDelete: (asset: Asset) => void
onViewTasks: (asset: Asset) => void
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
getSelectedCount?: () => number
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
@@ -225,7 +226,9 @@ export const createAssetColumns = (
cell: ({ row }) => {
const asset = row.original
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id
const taskDetail = asset.task_details?.find(t => t.task_type === taskType)
const taskId = taskDetail?.task_id
const assignedUserId = taskDetail?.assigned_user_id || null
return h(EditableTaskStatus, {
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
@@ -234,9 +237,13 @@ export const createAssetColumns = (
status,
taskId,
projectId: meta.projectId,
assignedUserId,
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
},
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
},
})
},
enableSorting: true,
@@ -297,8 +304,7 @@ export const createAssetColumns = (
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -83,7 +83,7 @@ import {
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { episodeService, type Episode } from '@/services/episode'
interface Props {
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const router = useRouter()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// Reactive state
const episodes = ref<Episode[]>([])
@@ -108,11 +108,7 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const canCreateEpisodes = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
const sortedEpisodes = computed(() => {
return [...episodes.value].sort((a, b) => {
@@ -168,7 +168,7 @@
</p>
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
</div>
@@ -1,123 +0,0 @@
<template>
<Card>
<CardHeader>
<CardTitle>File Upload Example</CardTitle>
<CardDescription>
Example showing how to integrate upload limit display and validation
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- Upload Limit Display -->
<UploadLimitDisplay />
<!-- File Upload -->
<div class="space-y-2">
<Label for="file-upload">Select File</Label>
<Input
id="file-upload"
type="file"
@change="handleFileSelect"
:disabled="uploading"
accept=".mov,.mp4,.avi,.mkv,.webm,.jpg,.jpeg,.png,.exr,.tiff"
/>
</div>
<!-- File Info -->
<div v-if="selectedFile" class="text-sm space-y-1">
<p><strong>File:</strong> {{ selectedFile.name }}</p>
<p><strong>Size:</strong> {{ formatFileSize(selectedFile.size) }}</p>
<p><strong>Type:</strong> {{ getFileType(selectedFile.name) }}</p>
</div>
<!-- Validation Errors -->
<div v-if="validationError" class="text-sm text-destructive">
{{ validationError }}
</div>
<!-- Upload Button -->
<Button
@click="uploadFile"
:disabled="!selectedFile || !!validationError || uploading"
class="w-full"
>
<Loader2 v-if="uploading" class="h-4 w-4 animate-spin mr-2" />
<Upload v-else class="h-4 w-4 mr-2" />
{{ uploading ? 'Uploading...' : 'Upload File' }}
</Button>
<!-- Success Message -->
<div v-if="uploadSuccess" class="text-sm text-green-600">
File uploaded successfully!
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Upload, Loader2 } from 'lucide-vue-next'
import UploadLimitDisplay from '@/components/settings/UploadLimitDisplay.vue'
import { validateFile, formatFileSize, isMovieFile, isImageFile } from '@/utils/fileValidation'
const selectedFile = ref<File | null>(null)
const validationError = ref<string | null>(null)
const uploading = ref(false)
const uploadSuccess = ref(false)
function getFileType(fileName: string): string {
if (isMovieFile(fileName)) return 'Movie'
if (isImageFile(fileName)) return 'Image'
return 'Other'
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) {
selectedFile.value = null
validationError.value = null
return
}
selectedFile.value = file
uploadSuccess.value = false
// Validate file
const result = await validateFile(file)
validationError.value = result.isValid ? null : result.error || 'Invalid file'
}
async function uploadFile() {
if (!selectedFile.value || validationError.value) return
uploading.value = true
try {
// Simulate upload delay
await new Promise(resolve => setTimeout(resolve, 2000))
// Here you would make the actual API call to upload the file
// const formData = new FormData()
// formData.append('file', selectedFile.value)
// await api.post('/tasks/123/attachments', formData)
uploadSuccess.value = true
selectedFile.value = null
validationError.value = null
// Reset file input
const fileInput = document.getElementById('file-upload') as HTMLInputElement
if (fileInput) fileInput.value = ''
} catch (error) {
validationError.value = 'Upload failed. Please try again.'
} finally {
uploading.value = false
}
}
</script>
+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)
+1 -1
View File
@@ -5,7 +5,7 @@
<AppSidebar />
<!-- Main Content Area -->
<SidebarInset class="flex-1 flex flex-col">
<SidebarInset class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<AppHeader />
+105 -6
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'
@@ -119,17 +176,25 @@ import {
Key,
Database,
BarChart3,
FileText,
RotateCcw,
LayoutDashboard,
Camera,
Package,
ListTodo,
ShieldCheck,
GanttChartSquare,
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import ProjectSwitcher from './ProjectSwitcher.vue'
import UserMenu from './UserMenu.vue'
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { state } = useSidebar()
const route = useRoute()
const showProjectFlyout = ref(false)
const user = computed(() => authStore.user)
const userRole = computed(() => authStore.user?.role || 'artist')
@@ -138,7 +203,41 @@ const isCollapsed = computed(() => state.value === 'collapsed')
// Check if on shot page - show column switch only on shot pages
const isOnShotPage = computed(() => {
return route.path.includes('/shots') || route.path.includes('/project/')
return route.path.endsWith('/shots') || route.path.includes('/project/')
})
// The project id when on a specific project's page (null on /projects, /projects/new, etc.)
const currentProjectId = computed(() => {
const id = route.params.projectId
const parsed = typeof id === 'string' ? parseInt(id) : NaN
return isNaN(parsed) ? null : parsed
})
// Sub-nav items shown under the "Projects" nav item when inside a specific project
const projectTabs = computed(() => {
const id = currentProjectId.value
if (id === null) return []
return [
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, route: `/projects/${id}` },
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
{ id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` },
{ id: 'schedule', label: 'Schedule', icon: GanttChartSquare, route: `/projects/${id}/schedule` },
{ id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` },
]
})
const activeProjectTab = computed(() => {
const id = currentProjectId.value
if (id === null) return null
const path = route.path
if (path === `/projects/${id}`) return 'overview'
if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule'
if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
return null
})
const props = withDefaults(defineProps<SidebarProps>(), {
@@ -151,7 +250,7 @@ const navigationItems = computed(() => {
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
]
if (userRole.value === 'coordinator' || authStore.isAdmin) {
if (isCoordinatorOrAdmin.value) {
baseItems.push(
{ title: 'Projects', url: '/projects', icon: FolderOpen },
{ title: 'Team', url: '/users', icon: Users }
@@ -176,6 +275,7 @@ const navigationItems = computed(() => {
// Admin-specific navigation items
const adminItems = computed(() => [
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
{ title: 'Role Management', url: '/admin/roles', icon: ShieldCheck },
])
// Developer-specific navigation items
@@ -183,8 +283,7 @@ const developerItems = computed(() => [
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
{ title: 'All Projects', url: '/developer/projects', icon: Database },
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }
])
// Mock recent projects - this would come from a store in real implementation
@@ -135,11 +135,13 @@ import {
} from "@/components/ui/sidebar";
import { useAuthStore } from "@/stores/auth";
import { useProjectsStore } from "@/stores/projects";
import { usePermission } from "@/composables/usePermission";
const router = useRouter();
const { isMobile } = useSidebar();
const authStore = useAuthStore();
const projectsStore = useProjectsStore();
const { isCoordinatorOrAdmin } = usePermission();
// Get projects and active project from store
const projects = computed(() => projectsStore.availableProjects);
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
const error = computed(() => projectsStore.error);
// Check if user can create projects
const canCreateProjects = computed(() => {
const user = authStore.user;
return user?.is_admin || user?.role === "coordinator";
});
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
const setActiveProject = (project: any) => {
projectsStore.setActiveProject(project);
+26 -37
View File
@@ -65,26 +65,11 @@
<User class="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
<Palette class="size-4" />
Preferences
</DropdownMenuItem>
<DropdownMenuItem @click="toggleNotifications">
<Bell class="size-4" />
Notifications
<span class="ml-auto text-xs text-muted-foreground">
{{ notificationsEnabled ? 'On' : 'Off' }}
</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<!-- Help and support -->
<DropdownMenuGroup>
<DropdownMenuItem @click="navigateTo('/help')">
<HelpCircle class="size-4" />
Help & Support
</DropdownMenuItem>
<DropdownMenuItem @click="showKeyboardShortcuts">
<Keyboard class="size-4" />
Keyboard Shortcuts
@@ -99,6 +84,24 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog v-model:open="showShortcutsDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div class="grid gap-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Toggle sidebar</span>
<span class="font-mono">/Ctrl + B</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Quick search</span>
<span class="font-mono">/Ctrl + K</span>
</div>
</div>
</DialogContent>
</Dialog>
</SidebarMenuItem>
</SidebarMenu>
</template>
@@ -111,11 +114,8 @@ import {
LogOut,
User,
Settings,
Bell,
Key,
Users,
Palette,
HelpCircle,
Keyboard,
} from 'lucide-vue-next'
import {
@@ -132,6 +132,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
SidebarMenu,
SidebarMenuButton,
@@ -181,8 +187,7 @@ const showRoleFeatures = computed(() => {
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
})
// Notifications state (this would typically come from a notifications store)
const notificationsEnabled = ref(true)
const showShortcutsDialog = ref(false)
// Actions
const navigateTo = (path: string) => {
@@ -198,24 +203,8 @@ const handleLogout = async () => {
}
}
const toggleNotifications = () => {
notificationsEnabled.value = !notificationsEnabled.value
// In a real app, this would update user preferences
console.log('Notifications toggled:', notificationsEnabled.value)
}
const showKeyboardShortcuts = () => {
// In a real app, this would open a modal with keyboard shortcuts
console.log('Keyboard shortcuts modal would open here')
// For now, just show an alert with some common shortcuts
alert(`Keyboard Shortcuts:
/Ctrl + B - Toggle sidebar
/Ctrl + K - Quick search
/Ctrl + , - Open preferences
/Ctrl + / - Show help
More shortcuts available in the help documentation.`)
showShortcutsDialog.value = true
}
@@ -104,7 +104,7 @@
import { computed } from 'vue'
import {
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush, Tag
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -144,7 +144,8 @@ const getDepartmentIcon = (department: string) => {
rigging: Wrench,
surfacing: Paintbrush
}
return icons[department] || Box
// Fallback for project-custom departments not in the standard icon map above
return icons[department] || Tag
}
const getFrameRateLabel = (frameRate: number) => {
@@ -41,14 +41,7 @@
>
<div class="flex items-center gap-3">
<Avatar class="h-8 w-8">
<AvatarImage
v-if="member.user_id"
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
/>
<AvatarImage
v-else
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
/>
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div>
@@ -65,21 +58,21 @@
<!-- Department Role -->
<div class="flex items-center gap-2">
<Label class="text-sm">Department:</Label>
<select
:value="member.department_role || 'none'"
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
<Select
:model-value="member.department_role || 'none'"
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
:disabled="isUpdatingMember === member.id"
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">None</option>
<option value="layout">Layout</option>
<option value="animation">Animation</option>
<option value="lighting">Lighting</option>
<option value="composite">Composite</option>
<option value="modeling">Modeling</option>
<option value="rigging">Rigging</option>
<option value="surfacing">Surfacing</option>
</select>
<SelectTrigger class="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
{{ role.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Joined Date -->
@@ -89,8 +82,8 @@
<!-- Actions -->
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -118,119 +111,84 @@
</Button>
</div>
<!-- Add Member Modal -->
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50"
@click="closeAddDialog"
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Add Team Member</h3>
<p class="text-sm text-muted-foreground">
<!-- Add Member Dialog -->
<Dialog v-model:open="showAddMemberDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
<DialogDescription>
Add a user to this project and assign their department role.
</p>
</div>
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<!-- User Selection -->
<div class="space-y-2">
<Label for="user">User</Label>
<select
v-model="newMember.userId"
:disabled="isAddingMember"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select a user</option>
<option
v-for="user in (availableUsers || [])"
<Label>User</Label>
<Select v-model="newMember.userId" :disabled="isAddingMember">
<SelectTrigger>
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id.toString()"
>
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Department Role -->
<div class="space-y-2">
<Label for="department">Department Role (Optional)</Label>
<select
v-model="newMember.departmentRole"
:disabled="isAddingMember"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">None</option>
<option value="layout">Layout</option>
<option value="animation">Animation</option>
<option value="lighting">Lighting</option>
<option value="composite">Composite</option>
<option value="modeling">Modeling</option>
<option value="rigging">Rigging</option>
<option value="surfacing">Surfacing</option>
</select>
<Label>Department Role (Optional)</Label>
<Select v-model="newMember.departmentRole" :disabled="isAddingMember">
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
{{ role.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<Button
variant="outline"
@click="closeAddDialog"
:disabled="isAddingMember"
>
<DialogFooter>
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
Cancel
</Button>
<Button
@click="addMember"
:disabled="!newMember.userId || isAddingMember"
>
<Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
<div v-if="isAddingMember" class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
<span>Adding...</span>
</div>
<span v-else>Add Member</span>
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Remove Member Confirmation -->
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50"
@click="showRemoveDialog = false"
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Remove Team Member</h3>
<p class="text-sm text-muted-foreground">
<AlertDialog v-model:open="showRemoveDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
This action cannot be undone.
</p>
</div>
<div class="flex justify-end gap-3">
<Button
variant="outline"
@click="showRemoveDialog = false"
>
Cancel
</Button>
<Button
@click="confirmRemoveMember"
variant="destructive"
>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove Member
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -242,16 +200,42 @@ import {
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user'
import { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth'
interface Props {
@@ -259,10 +243,24 @@ interface Props {
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = computed(() => {
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
value: department,
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}))
})
// State
const members = ref<ProjectMember[]>([])
const availableUsers = ref<User[]>([])
const allUsers = ref<User[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isUpdatingMember = ref<number | null>(null)
@@ -273,7 +271,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
const newMember = ref({
userId: '',
departmentRole: ''
departmentRole: 'none'
})
// Computed
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map(m => m.user_id))
return allUsers.value.filter(user => user.is_approved && !memberUserIds.has(user.id))
})
// Methods
@@ -284,40 +288,50 @@ const loadMembers = async () => {
members.value = await projectService.getProjectMembers(props.projectId)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load members'
toast({
title: 'Error',
description: 'Failed to load project members',
variant: 'destructive',
})
} finally {
isLoading.value = false
}
}
const loadAvailableUsers = async () => {
const loadUsers = async () => {
try {
const allUsers = await userService.getAllUsers()
// Filter out users who are already members
const memberUserIds = new Set(members.value.map(m => m.user_id))
availableUsers.value = allUsers.filter(user =>
user.is_approved && !memberUserIds.has(user.id)
)
allUsers.value = await userService.getUsers()
} catch (err) {
console.error('Failed to load users:', err)
error.value = 'Failed to load available users'
availableUsers.value = [] // Ensure it's always an array
toast({
title: 'Error',
description: 'Failed to load users',
variant: 'destructive',
})
}
}
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
try {
isUpdatingMember.value = memberId
await projectService.updateProjectMember(props.projectId, memberId, {
isUpdatingMember.value = member.id
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
department_role: departmentRole as any
})
// Update local state
const member = members.value.find(m => m.id === memberId)
if (member) {
member.department_role = departmentRole as any
const index = members.value.findIndex(m => m.id === member.id)
if (index !== -1) {
members.value[index] = updatedMember
}
toast({
title: 'Role updated',
description: 'Member department role has been updated',
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update member role'
toast({
title: 'Error',
description: 'Failed to update member role',
variant: 'destructive',
})
} finally {
isUpdatingMember.value = null
}
@@ -330,7 +344,7 @@ const addMember = async () => {
isAddingMember.value = true
const memberData = {
user_id: parseInt(newMember.value.userId),
department_role: newMember.value.departmentRole || undefined
department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
}
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
@@ -338,10 +352,16 @@ const addMember = async () => {
closeAddDialog()
// Refresh available users
await loadAvailableUsers()
toast({
title: 'Member added',
description: 'Team member has been added to the project',
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to add member'
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to add member',
variant: 'destructive',
})
} finally {
isAddingMember.value = false
}
@@ -358,19 +378,24 @@ const confirmRemoveMember = async () => {
try {
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
// Remove from local state
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
if (index !== -1) {
members.value.splice(index, 1)
}
toast({
title: 'Member removed',
description: 'Team member has been removed from the project',
})
} catch (err) {
toast({
title: 'Error',
description: 'Failed to remove member',
variant: 'destructive',
})
} finally {
showRemoveDialog.value = false
memberToRemove.value = null
// Refresh available users
await loadAvailableUsers()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to remove member'
}
}
@@ -378,10 +403,6 @@ const getUserInitials = (member: ProjectMember) => {
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
@@ -391,25 +412,18 @@ const formatDate = (dateString: string) => {
}
const openAddDialog = async () => {
try {
await loadAvailableUsers()
await loadUsers()
showAddMemberDialog.value = true
} catch (err) {
console.error('Failed to open add dialog:', err)
error.value = 'Failed to load user list'
}
}
const closeAddDialog = () => {
showAddMemberDialog.value = false
// Reset form
newMember.value = { userId: '', departmentRole: '' }
newMember.value = { userId: '', departmentRole: 'none' }
}
// Lifecycle
onMounted(() => {
loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
})
</script>
@@ -1,323 +0,0 @@
<template>
<div class="space-y-6">
<!-- Add Member Section -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">Add Team Member</h4>
</div>
<div class="flex gap-3">
<Select
:model-value="newMember.user_id"
@update:model-value="newMember.user_id = $event"
class="flex-1"
>
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id.toString()"
>
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</SelectItem>
</SelectContent>
</Select>
<Select
:model-value="newMember.department_role"
@update:model-value="newMember.department_role = $event"
class="w-40"
>
<SelectTrigger>
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No Department</SelectItem>
<SelectItem value="layout">Layout</SelectItem>
<SelectItem value="animation">Animation</SelectItem>
<SelectItem value="lighting">Lighting</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
<SelectItem value="modeling">Modeling</SelectItem>
<SelectItem value="rigging">Rigging</SelectItem>
<SelectItem value="surfacing">Surfacing</SelectItem>
</SelectContent>
</Select>
<Button
@click="addMember"
:disabled="!newMember.user_id || isLoading"
size="sm"
>
<Plus class="h-4 w-4 mr-2" />
Add
</Button>
</div>
</div>
<Separator />
<!-- Current Members -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">
Current Members ({{ members.length }})
</h4>
</div>
<!-- Loading State -->
<div
v-if="isLoading && members.length === 0"
class="flex items-center justify-center py-8"
>
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"
></div>
<span class="text-sm text-muted-foreground">Loading members...</span>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0" class="space-y-2">
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-3 rounded-lg border bg-card"
>
<div class="flex items-center gap-3">
<div
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
>
<User class="h-4 w-4 text-primary" />
</div>
<div>
<p class="font-medium text-sm">
{{ member.user_first_name }} {{ member.user_last_name }}
</p>
<p class="text-xs text-muted-foreground">
{{ member.user_email }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Select
:model-value="member.department_role || ''"
@update:model-value="(value) => updateMemberRole(member, value)"
>
<SelectTrigger class="w-32 h-8">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No Department</SelectItem>
<SelectItem value="layout">Layout</SelectItem>
<SelectItem value="animation">Animation</SelectItem>
<SelectItem value="lighting">Lighting</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
<SelectItem value="modeling">Modeling</SelectItem>
<SelectItem value="rigging">Rigging</SelectItem>
<SelectItem value="surfacing">Surfacing</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
@click="removeMember(member)"
:disabled="isLoading"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8">
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">
No members assigned to this project
</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end pt-4 border-t">
<Button @click="$emit('close')" variant="outline"> Close </Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { Plus, User, Users, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/components/ui/toast/use-toast";
import { projectService, type ProjectMember } from "@/services/project";
import { userService } from "@/services/user";
import type { User as UserType } from "@/types/auth";
import type { Project } from "@/stores/projects";
interface Props {
project: Project;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const { toast } = useToast();
// State
const members = ref<ProjectMember[]>([]);
const allUsers = ref<UserType[]>([]);
const isLoading = ref(false);
const newMember = ref({
user_id: "",
department_role: "",
});
// Computed
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map((m) => m.user_id));
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
});
// Methods
const loadMembers = async () => {
try {
isLoading.value = true;
members.value = await projectService.getProjectMembers(props.project.id);
} catch (error) {
toast({
title: "Error",
description: "Failed to load project members",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const loadUsers = async () => {
try {
allUsers.value = await userService.getUsers();
} catch (error) {
toast({
title: "Error",
description: "Failed to load users",
variant: "destructive",
});
}
};
const addMember = async () => {
if (!newMember.value.user_id) return;
try {
isLoading.value = true;
const memberData = {
user_id: parseInt(newMember.value.user_id),
department_role: newMember.value.department_role || undefined,
};
const addedMember = await projectService.addProjectMember(
props.project.id,
memberData
);
members.value.push(addedMember);
// Reset form
newMember.value = {
user_id: "",
department_role: "",
};
toast({
title: "Member added",
description: "Team member has been added to the project",
});
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to add member",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
try {
isLoading.value = true;
const updatedMember = await projectService.updateProjectMember(
props.project.id,
member.id,
{ department_role: newRole || undefined }
);
const index = members.value.findIndex((m) => m.id === member.id);
if (index !== -1) {
members.value[index] = updatedMember;
}
toast({
title: "Role updated",
description: "Member department role has been updated",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to update member role",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const removeMember = async (member: ProjectMember) => {
try {
isLoading.value = true;
await projectService.removeProjectMember(props.project.id, member.id);
const index = members.value.findIndex((m) => m.id === member.id);
if (index !== -1) {
members.value.splice(index, 1);
}
toast({
title: "Member removed",
description: "Team member has been removed from the project",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to remove member",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
// Lifecycle
onMounted(() => {
loadMembers();
loadUsers();
});
</script>
@@ -1,178 +0,0 @@
<template>
<div class="w-full">
<div class="grid w-full grid-cols-5 h-auto bg-muted/50 p-1 rounded-md">
<button
v-for="tab in tabs"
:key="tab.id"
@click="setActiveTab(tab.id)"
:class="[
'flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 px-1 sm:px-3 min-h-[3rem] sm:min-h-[2.5rem] transition-all duration-200 text-center rounded-sm',
activeTab === tab.id
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
>
<div class="flex items-center gap-1 sm:gap-2">
<component :is="tab.icon" class="h-4 w-4 flex-shrink-0" />
<span class="hidden sm:inline text-sm font-medium">{{ tab.label }}</span>
<span class="sm:hidden text-xs font-medium">{{ getMobileLabel(tab) }}</span>
</div>
<Badge
v-if="tab.count !== undefined"
variant="secondary"
class="text-xs hidden sm:inline-flex min-w-[1.5rem] h-5"
>
{{ tab.count }}
</Badge>
<!-- Mobile count display -->
<div
v-if="tab.count !== undefined"
class="sm:hidden text-xs text-muted-foreground font-medium"
>
{{ tab.count }}
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LayoutDashboard, Camera, Package, ListTodo, Settings } from "lucide-vue-next";
import { Badge } from "@/components/ui/badge";
interface Tab {
id: string;
label: string;
icon: any;
route: string;
count?: number;
}
interface Props {
projectId: number;
shotCount?: number;
assetCount?: number;
taskCount?: number;
}
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
// Define available tabs
const tabs = computed<Tab[]>(() => [
{
id: "overview",
label: "Overview",
icon: LayoutDashboard,
route: `/projects/${props.projectId}`,
},
{
id: "shots",
label: "Shots",
icon: Camera,
route: `/projects/${props.projectId}/shots`,
count: props.shotCount,
},
{
id: "assets",
label: "Assets",
icon: Package,
route: `/projects/${props.projectId}/assets`,
count: props.assetCount,
},
{
id: "tasks",
label: "Tasks",
icon: ListTodo,
route: `/projects/${props.projectId}/tasks`,
count: props.taskCount,
},
{
id: "settings",
label: "Settings",
icon: Settings,
route: `/projects/${props.projectId}/settings`,
},
]);
// Determine active tab based on current route
const activeTab = computed(() => {
const currentPath = route.path;
// Debug logging
console.log('ProjectTabs - Current path:', currentPath);
console.log('ProjectTabs - Project ID:', props.projectId);
if (currentPath === `/projects/${props.projectId}`) {
console.log('ProjectTabs - Active tab: overview');
return "overview";
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
console.log('ProjectTabs - Active tab: shots');
return "shots";
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
console.log('ProjectTabs - Active tab: assets');
return "assets";
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
console.log('ProjectTabs - Active tab: tasks');
return "tasks";
} else if (
currentPath.startsWith(`/projects/${props.projectId}/settings`)
) {
console.log('ProjectTabs - Active tab: settings');
return "settings";
}
console.log('ProjectTabs - Active tab: overview (default)');
return "overview";
});
// Set active tab and navigate
const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId);
console.log('ProjectTabs - Setting active tab:', tabId);
console.log('ProjectTabs - Tab found:', tab);
console.log('ProjectTabs - Current route path:', route.path);
console.log('ProjectTabs - Target route:', tab?.route);
if (tab) {
console.log('ProjectTabs - Navigating to:', tab.route);
router.push(tab.route).catch(err => {
console.error('ProjectTabs - Navigation error:', err);
});
} else {
console.log('ProjectTabs - Tab not found');
}
};
// Get mobile label for tabs
const getMobileLabel = (tab: Tab) => {
switch (tab.id) {
case "settings":
return "Settings";
case "overview":
return "Info";
case "shots":
return "Shots";
case "assets":
return "Assets";
case "tasks":
return "Tasks";
default:
return tab.label.charAt(0);
}
};
// Watch for route changes to ensure tab state persistence
watch(
() => route.path,
() => {
// Tab state is automatically updated via activeTab computed property
// This ensures tab state persistence during project navigation
},
{ immediate: true }
);
</script>
@@ -1,281 +0,0 @@
<template>
<div class="space-y-4">
<!-- Table Header Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">
{{ episodeId ? `Episode ${episodeId} Shots` : "All Shots" }}
</h3>
<Badge variant="secondary" v-if="shots.length > 0">
{{ shots.length }} shot{{ shots.length !== 1 ? "s" : "" }}
</Badge>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="refreshShots">
<RefreshCw class="h-4 w-4 mr-2" />
Refresh
</Button>
<Button size="sm" @click="createShot" v-if="episodeId">
<Plus class="h-4 w-4 mr-2" />
Add Shot
</Button>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
></div>
<span class="text-muted-foreground">Loading shots...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
<p class="text-muted-foreground">{{ error }}</p>
<Button variant="outline" size="sm" @click="refreshShots" class="mt-2">
Try Again
</Button>
</div>
<!-- Empty State -->
<div v-else-if="shots.length === 0" class="text-center py-12">
<Camera class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-semibold mb-2">No shots found</h3>
<p class="text-muted-foreground mb-4">
{{
episodeId
? "This episode doesn't have any shots yet."
: "No shots found for the selected criteria."
}}
</p>
<Button @click="createShot" v-if="episodeId">
<Plus class="h-4 w-4 mr-2" />
Create First Shot
</Button>
</div>
<!-- Shots Table -->
<div v-else class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Shot Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Frames</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tasks</TableHead>
<TableHead>Updated</TableHead>
<TableHead class="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="shot in shots"
:key="shot.id"
class="hover:bg-muted/50"
>
<TableCell class="font-medium">{{ shot.name }}</TableCell>
<TableCell>
<span
v-if="shot.description"
class="text-sm text-muted-foreground"
>
{{ shot.description }}
</span>
<span v-else class="text-sm text-muted-foreground italic"
>No description</span
>
</TableCell>
<TableCell>
<span class="font-mono text-sm">
{{ shot.frame_start }}-{{ shot.frame_end }}
</span>
<span class="text-xs text-muted-foreground ml-2">
({{ shot.frame_end - shot.frame_start + 1 }} frames)
</span>
</TableCell>
<TableCell>
<Badge :variant="getStatusVariant(shot.status)">
{{ formatStatus(shot.status) }}
</Badge>
</TableCell>
<TableCell>
<span class="text-sm">{{ shot.task_count }} tasks</span>
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ formatDate(shot.updated_at) }}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="editShot(shot)">
<Edit class="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem @click="viewTasks(shot)">
<CheckSquare class="h-4 w-4 mr-2" />
View Tasks
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@click="deleteShot(shot)"
class="text-destructive"
>
<Trash2 class="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import {
Camera,
Plus,
RefreshCw,
AlertCircle,
MoreHorizontal,
Edit,
CheckSquare,
Trash2,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { shotService, type Shot, ShotStatus } from "@/services/shot";
interface Props {
projectId: number;
episodeId?: number | null;
}
const props = defineProps<Props>();
// Reactive state
const shots = ref<Shot[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
// Methods
const loadShots = async () => {
if (!props.projectId) return;
try {
isLoading.value = true;
error.value = null;
const shotsData = await shotService.getShots(
props.projectId,
props.episodeId || undefined
);
shots.value = shotsData;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to load shots";
shots.value = [];
} finally {
isLoading.value = false;
}
};
const refreshShots = () => {
loadShots();
};
const createShot = () => {
// TODO: Implement shot creation dialog
console.log("Create shot for episode:", props.episodeId);
};
const editShot = (shot: Shot) => {
// TODO: Implement shot editing
console.log("Edit shot:", shot);
};
const viewTasks = (shot: Shot) => {
// TODO: Navigate to shot tasks view
console.log("View tasks for shot:", shot);
};
const deleteShot = async (shot: Shot) => {
// TODO: Implement shot deletion with confirmation
console.log("Delete shot:", shot);
};
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return "secondary";
case ShotStatus.IN_PROGRESS:
return "default";
case ShotStatus.ON_HOLD:
return "outline";
case ShotStatus.COMPLETED:
return "default";
case ShotStatus.APPROVED:
return "default";
default:
return "secondary";
}
};
const formatStatus = (status: ShotStatus) => {
return status
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Watchers
watch(
() => [props.projectId, props.episodeId],
() => {
loadShots();
},
{ immediate: true }
);
// Lifecycle
onMounted(() => {
loadShots();
});
</script>
@@ -0,0 +1,175 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Submission Configuration</h3>
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
</div>
</div>
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
Loading submission configuration...
</div>
<Tabs v-else default-value="shot" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="shot">Shot Tasks</TabsTrigger>
<TabsTrigger value="asset">Asset Tasks</TabsTrigger>
</TabsList>
<TabsContent value="shot" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in shotTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No shot task types configured for this project yet.
</div>
</TabsContent>
<TabsContent value="asset" class="space-y-4 mt-4">
<SubmissionTypeConfigCard
v-for="taskType in assetTaskTypes"
:key="taskType"
:task-type="taskType"
:config="getConfig(taskType)"
/>
<div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
No asset task types configured for this project yet.
</div>
</TabsContent>
</Tabs>
<div class="flex justify-end">
<Button :disabled="isLoading || isSaving" @click="onSave">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Save Submission Configuration
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { customTaskTypeService } from '@/services/customTaskType'
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const { toast } = useToast()
const isLoading = ref(false)
const isSaving = ref(false)
const shotTaskTypes = ref<string[]>([])
const assetTaskTypes = ref<string[]>([])
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
const getConfig = (taskType: string): SubmissionTypeConfig => {
if (!formState[taskType]) {
formState[taskType] = {
allowed_extensions: [],
naming_pattern_is_regex: false,
naming_pattern: '',
check_naming: true,
required: false,
check_movie_spec: false,
movie_resolution: '',
movie_format: '',
movie_codec: '',
movie_frame_rate: null
}
}
return formState[taskType]
}
const load = async () => {
try {
isLoading.value = true
const [allTypes, config] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
projectService.getProjectSubmissionConfig(props.projectId)
])
shotTaskTypes.value = allTypes.shot_task_types
assetTaskTypes.value = allTypes.asset_task_types
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
formState[taskType] = {
allowed_extensions: [...cfg.allowed_extensions],
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
naming_pattern: cfg.naming_pattern || '',
check_naming: cfg.check_naming,
required: cfg.required,
check_movie_spec: cfg.check_movie_spec,
movie_resolution: cfg.movie_resolution || '',
movie_format: cfg.movie_format || '',
movie_codec: cfg.movie_codec || '',
movie_frame_rate: cfg.movie_frame_rate ?? null
}
}
} catch (error) {
console.error('Failed to load submission configuration:', error)
toast({
title: 'Error',
description: 'Failed to load submission configuration',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const onSave = async () => {
try {
isSaving.value = true
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
const config = formState[taskType]
if (!config) continue
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|| config.required || config.check_movie_spec
if (hasConfig) {
configByTaskType[taskType] = {
allowed_extensions: config.allowed_extensions,
naming_pattern_is_regex: config.naming_pattern_is_regex,
naming_pattern: config.naming_pattern || null,
check_naming: config.check_naming,
required: config.required,
check_movie_spec: config.check_movie_spec,
movie_resolution: config.movie_resolution || null,
movie_format: config.movie_format || null,
movie_codec: config.movie_codec || null,
movie_frame_rate: config.movie_frame_rate || null
}
}
}
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
toast({
title: 'Submission Configuration Updated',
description: 'Submission rules have been saved successfully'
})
} catch (error: any) {
console.error('Failed to save submission configuration:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to save submission configuration',
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,173 @@
<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
<Switch
:id="`${taskType}_required`"
:model-value="config.required"
@update:model-value="(val) => (config.required = !!val)"
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label class="text-xs">Allowed File Types</Label>
<div class="flex flex-wrap gap-3 mt-1">
<label
v-for="ext in ALLOWED_EXTENSIONS"
:key="ext"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="config.allowed_extensions.includes(ext)"
@update:model-value="(val) => toggleExtension(ext, !!val)"
/>
{{ ext }}
</label>
</div>
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
:model-value="config.naming_pattern_is_regex"
@update:model-value="(val) => (config.naming_pattern_is_regex = !!val)"
/>
Regex
</label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_naming`"
:model-value="config.check_naming"
@update:model-value="(val) => (config.check_naming = !!val)"
/>
</div>
</div>
</div>
<Input
:id="`${taskType}_pattern`"
v-model="config.naming_pattern"
:placeholder="config.naming_pattern_is_regex ? 'e.g. ^[A-Z]+\\d{3}_layout_v\\d{3}$' : 'e.g. {project_code}_{name}_{task_type}_v{version}'"
class="h-8 font-mono"
/>
<p v-if="config.naming_pattern_is_regex" class="text-xs text-muted-foreground mt-1">
Matched as a regular expression against the filename (without extension)
</p>
<p v-else class="text-xs text-muted-foreground mt-1">
Tokens: <code>{name}</code> (shot/asset name), <code>{task_name}</code>, <code>{task_type}</code>,
<code>{project_name}</code>, <code>{project_code}</code>, <code>{version}</code> (auto 3-digit)
</p>
</div>
<div>
<div class="flex items-center justify-between">
<Label class="text-xs">Movie Spec</Label>
<div class="flex items-center gap-2">
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
<Switch
:id="`${taskType}_check_movie_spec`"
:model-value="config.check_movie_spec"
@update:model-value="(val) => (config.check_movie_spec = !!val)"
/>
</div>
</div>
<div v-if="config.check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
<div>
<Input
v-model="config.movie_resolution"
placeholder="e.g. 1920x1080"
class="h-8"
/>
</div>
<div>
<Select
:model-value="config.movie_format || undefined"
@update:model-value="(val) => (config.movie_format = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_codec || undefined"
@update:model-value="(val) => (config.movie_codec = val ? String(val) : '')"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Codec" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select
:model-value="config.movie_frame_rate ? String(config.movie_frame_rate) : undefined"
@update:model-value="(val) => (config.movie_frame_rate = val ? Number(val) : null)"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="Frame rate" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { SubmissionTypeConfig } from '@/services/project'
const props = defineProps<{
taskType: string
config: SubmissionTypeConfig
}>()
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
const ALLOWED_EXTENSIONS = [
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
'.pdf', '.txt', '.doc', '.docx',
'.zip', '.rar', '.7z',
'.ma', '.usd', '.usda', '.usdc'
]
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
function toggleExtension(ext: string, checked: boolean) {
if (checked) {
if (!props.config.allowed_extensions.includes(ext)) props.config.allowed_extensions.push(ext)
} else {
props.config.allowed_extensions = props.config.allowed_extensions.filter(e => e !== ext)
}
}
</script>
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import { notificationService } from '@/services/notifications'
@@ -97,7 +97,7 @@ interface Props {
const props = defineProps<Props>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
const { toast } = useToast()
// State
@@ -108,11 +108,7 @@ const isEditing = ref(false)
const showNotification = ref(false)
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
// Methods
const loadSpecs = async () => {
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
@@ -73,7 +73,7 @@ interface Emits {
const props = defineProps<Props>()
defineEmits<Emits>()
const authStore = useAuthStore()
const { isCoordinatorOrAdmin } = usePermission()
// State
const isOpen = ref(props.defaultOpen || false)
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
const specs = ref<ProjectTechnicalSpecs | undefined>()
// Computed properties
const canEdit = computed(() => {
const userRole = authStore.userRole
const isAdmin = authStore.user?.is_admin
return userRole === 'coordinator' || isAdmin
})
const canEdit = computed(() => isCoordinatorOrAdmin.value)
const hasSpecs = computed(() => {
if (!specs.value) return false
@@ -0,0 +1,230 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
<DialogDescription>
{{ role?.is_system
? 'System role permissions are editable, but the name and description cannot be changed.'
: 'Define a role and the permissions it grants.' }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
<div class="space-y-2">
<Label for="role_name">Name</Label>
<Input
id="role_name"
v-model="formData.name"
placeholder="e.g. Reviewer"
required
:disabled="role?.is_system"
/>
</div>
<div class="space-y-2">
<Label for="role_description">Description</Label>
<Input
id="role_description"
v-model="formData.description"
placeholder="What this role is for"
:disabled="role?.is_system"
/>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col gap-2">
<div class="flex items-center justify-between flex-shrink-0">
<Label>Permissions</Label>
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
</div>
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
<div class="space-y-2">
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 text-sm font-medium capitalize">
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
{{ resource }}
</div>
<button
type="button"
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
@click="toggleAllForResource(resource)"
>
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
</button>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
<label
v-for="perm in permissionsForResource(resource)"
:key="perm.id"
class="flex items-center gap-1.5 text-sm cursor-pointer"
:title="perm.description || undefined"
>
<Checkbox
:model-value="selectedPermissionIds.has(perm.id)"
@update:model-value="(val) => togglePermission(perm.id, !!val)"
/>
{{ actionLabel(perm.action) }}
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
<DialogFooter class="flex-shrink-0">
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
} from 'lucide-vue-next'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import type { Role, Permission } from '@/services/role'
interface Props {
open: boolean
role: Role | null
permissions: Permission[]
saving?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
}>()
const formData = ref({ name: '', description: '' })
const selectedPermissionIds = ref<Set<number>>(new Set())
const formError = ref('')
// Resources grouped into scannable sections, in a fixed, deliberate order
// (not alphabetical) so related concepts sit together.
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
{ title: 'Review', resources: ['review'] },
{ title: 'Notes', resources: ['note'] },
]
const RESOURCE_ICONS: Record<string, any> = {
asset: Package,
shot: Camera,
task: ListTodo,
assignment: UserCheck,
submission: UploadCloud,
upload: Paperclip,
review: CheckCircle2,
note: MessageSquare,
}
const ACTION_LABELS: Record<string, string> = {
create: 'Create',
edit: 'Edit',
delete: 'Delete',
publish: 'Approve',
retake: 'Request Retake',
view_internal: 'View Internal',
view_client: 'View Client',
change_status: 'Change Status',
edit_self: 'Edit Own',
delete_self: 'Delete Own',
edit_other: "Edit Others'",
delete_other: "Delete Others'",
}
function resourceIcon(resource: string) {
return RESOURCE_ICONS[resource] ?? Shield
}
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action
}
// Only show groups/resources that actually have permissions in the catalog,
// and fall back to a catch-all group for any future resource not yet
// assigned to a section above (so nothing silently disappears from the UI).
const permissionGroups = computed(() => {
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
const groups = RESOURCE_GROUPS
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
.filter(g => g.resources.length > 0)
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
if (otherResources.length > 0) {
groups.push({ title: 'Other', resources: otherResources })
}
return groups
})
function permissionsForResource(resource: string): Permission[] {
return props.permissions.filter(p => p.resource === resource)
}
function allSelectedForResource(resource: string): boolean {
const perms = permissionsForResource(resource)
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
}
function toggleAllForResource(resource: string) {
const perms = permissionsForResource(resource)
const shouldSelect = !allSelectedForResource(resource)
for (const perm of perms) {
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
else selectedPermissionIds.value.delete(perm.id)
}
}
function togglePermission(id: number, checked: boolean) {
if (checked) selectedPermissionIds.value.add(id)
else selectedPermissionIds.value.delete(id)
}
watch(() => props.open, (isOpen) => {
if (!isOpen) return
formError.value = ''
formData.value = {
name: props.role?.name ?? '',
description: props.role?.description ?? ''
}
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
})
function handleSubmit() {
formError.value = ''
if (!props.role && !formData.value.name.trim()) {
formError.value = 'Name is required'
return
}
const payload = props.role?.is_system
? { permission_ids: [...selectedPermissionIds.value] }
: {
name: formData.value.name,
description: formData.value.description || undefined,
permission_ids: [...selectedPermissionIds.value]
}
emit('saved', payload)
}
</script>
@@ -0,0 +1,810 @@
<template>
<div class="h-full flex flex-col">
<!-- Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 border-b">
<div class="flex flex-wrap items-center gap-3">
<Select
:model-value="episodeFilter === null ? 'all' : String(episodeFilter)"
@update:model-value="handleEpisodeFilterChange"
>
<SelectTrigger class="w-48 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Episodes</SelectItem>
<SelectItem v-for="ep in episodeOptions" :key="ep.id" :value="String(ep.id)">{{ ep.name }}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-8" @click="toggleAllGroups">
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="option in GROUP_BY_OPTIONS"
:key="option"
:variant="groupBy === option ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs"
@click="groupBy = option"
>
{{ groupByLabel(option) }}
</Button>
</div>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="scale in SCALES"
:key="scale"
:variant="viewScale === scale ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs capitalize"
@click="viewScale = scale"
>
{{ scale }}
</Button>
</div>
<div class="flex items-center gap-1.5">
<div class="w-36">
<DatePicker v-model="manualRangeStart" placeholder="Start date" />
</div>
<span class="text-xs text-muted-foreground">to</span>
<div class="w-36">
<DatePicker v-model="manualRangeEnd" placeholder="End date" :min="manualRangeStart" />
</div>
<Button
v-if="manualRangeStart || manualRangeEnd"
variant="ghost"
size="sm"
class="h-8 px-2 text-xs"
@click="manualRangeStart = ''; manualRangeEnd = ''"
>
Reset
</Button>
</div>
</div>
<div v-if="legendStatuses.length > 0" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span v-for="status in legendStatuses" :key="status.id" class="flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm flex-shrink-0" :style="{ backgroundColor: status.color }"></span>
{{ status.name }}
</span>
</div>
</div>
<div v-if="isLoading" class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Loading schedule...
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }}
</div>
<div v-else class="flex-1 flex flex-col overflow-hidden">
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="rowGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
</div>
<div v-else class="flex-1 flex overflow-hidden">
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
<div
ref="frozenPaneRef"
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
:style="{ width: FROZEN_WIDTH + 'px' }"
@scroll="handleFrozenScroll"
>
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
Task Type / Task
</div>
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
Task Status
</div>
</div>
<div v-for="group in rowGroups" :key="group.key">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.key)"
>
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
<component
:is="isCollapsed(group.key) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ group.label }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
</div>
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
{{ taskRowLabel(task) }}
</div>
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
<EditableTaskStatus
:task-id="task.id"
:status="task.status"
:project-id="projectId"
show-assignee
:assigned-user-id="task.assigned_user_id"
@status-updated="handleStatusUpdated"
@assignment-updated="handleAssignmentUpdated"
/>
</div>
</div>
</template>
</div>
</div>
<!-- Timeline pane: date axis + bars, scrolls both ways -->
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<!-- Date axis header: month row on top, date-number row below -->
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
<div class="relative h-5 border-b">
<div
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
{{ marker.label }}
</div>
</div>
<div class="relative h-6">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
>
{{ marker.label }}
</div>
</div>
</div>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<div v-for="group in rowGroups" :key="group.key">
<div
class="relative border-b bg-muted/40 cursor-pointer"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.key)"
>
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
class="relative border-b hover:bg-muted/30"
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
>
<div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
:style="taskBarStyle(task)"
:title="taskBarTitle(task)"
>
<div
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
style="right: 6px;"
@mousedown="startDrag($event, task, 'move')"
></div>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-start')"
></div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-end')"
></div>
<span
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
>
{{ taskRowLabel(task) }}
</span>
</div>
<!-- Submission date markers -->
<div
v-for="date in submissionDatesFor(task.id)"
:key="date"
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
:title="`Submitted ${formatDate(date)}`"
></div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
</div>
</div>
</div>
</div>
</div>
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask.id"
@close="closeDetailPanel"
@task-updated="loadTasks"
/>
</DetailPanelOverlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { DatePicker } from '@/components/ui/date-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
projectId: number
}>()
const taskStatusesStore = useTaskStatusesStore()
const { toast } = useToast()
const {
selectedEntity: selectedTask,
showMobileDetail,
showPanel,
closeDetailPanel,
selectEntity: selectTask
} = useDetailPanel<TaskListItem>({ sessionStorageKey: 'scheduleGantt.detailPanelEnabled' })
const isLoading = ref(false)
const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([])
const submissionDates = ref<SubmissionDateInfo[]>([])
const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56
const STATUS_COLUMN_WIDTH = 200 // status select (130px) + assignee avatar button, with padding
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
const HEADER_HEIGHT = 44
const GROUP_ROW_HEIGHT = 32
const TASK_ROW_HEIGHT = 36
// The frozen (Task Type/Task Status) pane and the timeline pane are two
// independently-scrolled elements so the horizontal scrollbar only ever
// spans the timeline. Vertical scroll position is kept in sync between them.
const frozenPaneRef = ref<HTMLElement | null>(null)
const timelinePaneRef = ref<HTMLElement | null>(null)
let syncingScroll = false
function handleFrozenScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
syncingScroll = false
}
function handleTimelineScroll() {
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
syncingScroll = true
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
syncingScroll = false
}
const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number]
const viewScale = ref<ViewScale>('week')
const SCALE_PIXELS_PER_DAY: Record<ViewScale, number> = { day: 40, week: 22, month: 6 }
const pixelsPerDay = computed(() => SCALE_PIXELS_PER_DAY[viewScale.value])
const manualRangeStart = ref('')
const manualRangeEnd = ref('')
function parseDate(dateStr: string): Date {
return new Date(`${dateStr}T00:00:00Z`)
}
function toDateString(d: Date): string {
return d.toISOString().slice(0, 10)
}
function addDays(d: Date, days: number): Date {
const copy = new Date(d)
copy.setUTCDate(copy.getUTCDate() + days)
return copy
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86400000)
}
function formatTaskType(taskType: string): string {
return taskType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '?'
return parseDate(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
}
async function loadTasks() {
try {
isLoading.value = true
error.value = null
tasks.value = await taskService.getTasks({ projectId: props.projectId, limit: 1000 })
} catch (err: any) {
console.error('Failed to load schedule tasks:', err)
error.value = err.response?.data?.detail || 'Failed to load schedule'
} finally {
isLoading.value = false
}
}
async function loadSubmissionDates() {
try {
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
} catch (err) {
console.error('Failed to load submission dates:', err)
}
}
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
const submissionDatesByTask = computed(() => {
const map = new Map<number, string[]>()
for (const s of submissionDates.value) {
const day = s.submitted_at.slice(0, 10)
const existing = map.get(s.task_id)
if (existing) {
if (!existing.includes(day)) existing.push(day)
} else {
map.set(s.task_id, [day])
}
}
return map
})
function submissionDatesFor(taskId: number): string[] {
return submissionDatesByTask.value.get(taskId) || []
}
const episodeOptions = computed(() => {
const map = new Map<number, string>()
for (const t of tasks.value) {
if (t.episode_id && t.episode_name && !map.has(t.episode_id)) {
map.set(t.episode_id, t.episode_name)
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
})
function handleEpisodeFilterChange(value: unknown) {
episodeFilter.value = value === 'all' ? null : Number(value)
}
const filteredTasks = computed(() => {
if (episodeFilter.value === null) return tasks.value
return tasks.value.filter(t => t.episode_id === episodeFilter.value)
})
const scheduledTasks = computed(() => filteredTasks.value.filter(t => t.start_date && t.deadline))
const unscheduledCount = computed(() => filteredTasks.value.length - scheduledTasks.value.length)
const autoWindowStart = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let min: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.start_date!)
if (!min || d < min) min = d
}
if (!min) return null
const padded = new Date(min)
padded.setUTCDate(padded.getUTCDate() - 3)
return padded
})
const autoWindowEnd = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let max: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.deadline!)
if (!max || d > max) max = d
}
if (!max) return null
const padded = new Date(max)
padded.setUTCDate(padded.getUTCDate() + 3)
return padded
})
const windowStart = computed<Date | null>(() => manualRangeStart.value ? parseDate(manualRangeStart.value) : autoWindowStart.value)
const windowEnd = computed<Date | null>(() => manualRangeEnd.value ? parseDate(manualRangeEnd.value) : autoWindowEnd.value)
const chartWidth = computed(() => {
if (!windowStart.value || !windowEnd.value) return 0
return Math.max(daysBetween(windowStart.value, windowEnd.value) * pixelsPerDay.value, 400)
})
function dateToLeft(date: Date): number {
if (!windowStart.value) return 0
return daysBetween(windowStart.value, date) * pixelsPerDay.value
}
// One bounded, window-clipped cell per calendar month touching [windowStart, windowEnd].
function computeMonthSegments(labelOptions: Intl.DateTimeFormatOptions): { left: number; width: number; label: string }[] {
if (!windowStart.value || !windowEnd.value) return []
const segments: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), windowStart.value.getUTCMonth(), 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextMonth = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextMonth), rightBound) - left, 2)
segments.push({ left, width, label: cursor.toLocaleDateString('en-US', labelOptions) })
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
}
return segments
}
const axisMarkers = computed<{ left: number; width?: number; label: string }[]>(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
return computeMonthSegments({ month: 'short', timeZone: 'UTC' })
}
const markers: { left: number; label: string }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
markers.push({
left: dateToLeft(cursor),
label: String(cursor.getUTCDate())
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return markers
})
// Coarser grouping row shown above axisMarkers: month spans (day/week scale) or year spans (month scale).
const topAxisMarkers = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
const markers: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), 0, 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextYear = new Date(Date.UTC(cursor.getUTCFullYear() + 1, 0, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextYear), rightBound) - left, 2)
markers.push({ left, width, label: String(cursor.getUTCFullYear()) })
cursor.setUTCFullYear(cursor.getUTCFullYear() + 1)
}
return markers
}
return computeMonthSegments({ month: 'long', year: 'numeric', timeZone: 'UTC' })
})
const weekendColumns = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
const columns: { left: number }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
const day = cursor.getUTCDay()
if (day === 0 || day === 6) {
columns.push({ left: dateToLeft(cursor) })
}
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return columns
})
const todayLeft = computed(() => {
if (!windowStart.value || !windowEnd.value) return null
const now = new Date()
const todayUtc = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
if (todayUtc < windowStart.value || todayUtc > windowEnd.value) return null
return dateToLeft(todayUtc)
})
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
type GroupBy = typeof GROUP_BY_OPTIONS[number]
const groupBy = ref<GroupBy>('taskType')
function groupByLabel(mode: GroupBy): string {
return mode === 'taskType' ? 'Task Type' : 'Shot'
}
function entityName(task: TaskListItem): string {
return task.shot_name || task.asset_name || task.name
}
// The row label shows whichever dimension ISN'T already the group header:
// task type when grouped by shot, and the shot/asset name when grouped by task type.
function taskRowLabel(task: TaskListItem): string {
return groupBy.value === 'taskType' ? entityName(task) : formatTaskType(task.task_type)
}
interface RowGroup {
key: string
label: string
tasks: TaskListItem[]
barLeft: number | null
barWidth: number | null
}
const rowGroups = computed<RowGroup[]>(() => {
const byKey = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) {
const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key)!.push(t)
}
return Array.from(byKey.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
if (windowStart.value) {
let minStart: Date | null = null
let maxEnd: Date | null = null
for (const t of sorted) {
const s = parseDate(t.start_date!)
const e = parseDate(t.deadline!)
if (!minStart || s < minStart) minStart = s
if (!maxEnd || e > maxEnd) maxEnd = e
}
barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
}
const label = groupBy.value === 'taskType' ? formatTaskType(key) : key
return { key, label, tasks: sorted, barLeft, barWidth }
})
})
function isCollapsed(key: string): boolean {
return collapsedGroups.value.has(key)
}
function toggleGroup(key: string) {
const next = new Set(collapsedGroups.value)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedGroups.value = next
}
const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? new Set()
: new Set(rowGroups.value.map(g => g.key))
}
watch(groupBy, () => {
collapsedGroups.value = new Set()
})
function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
}
function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id
}
function handleStatusUpdated(taskId: number, newStatus: string) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.status = newStatus
}
function handleAssignmentUpdated(taskId: number, userId: number | null) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assigned_user_id = userId ?? undefined
}
// --- Drag to reschedule ---
interface DragState {
taskId: number
mode: 'move' | 'resize-start' | 'resize-end'
startX: number
originalStart: Date
originalEnd: Date
currentStart: Date
currentEnd: Date
}
const dragState = ref<DragState | null>(null)
function startDrag(event: MouseEvent, task: TaskListItem, mode: DragState['mode']) {
event.preventDefault()
event.stopPropagation()
const originalStart = parseDate(task.start_date!)
const originalEnd = parseDate(task.deadline!)
dragState.value = {
taskId: task.id,
mode,
startX: event.clientX,
originalStart,
originalEnd,
currentStart: originalStart,
currentEnd: originalEnd
}
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragMove(event: MouseEvent) {
const drag = dragState.value
if (!drag) return
const deltaX = event.clientX - drag.startX
const deltaDays = Math.round(deltaX / pixelsPerDay.value)
let newStart = drag.originalStart
let newEnd = drag.originalEnd
if (drag.mode === 'move') {
newStart = addDays(drag.originalStart, deltaDays)
newEnd = addDays(drag.originalEnd, deltaDays)
} else if (drag.mode === 'resize-start') {
newStart = addDays(drag.originalStart, deltaDays)
if (newStart >= drag.originalEnd) newStart = addDays(drag.originalEnd, -1)
} else if (drag.mode === 'resize-end') {
newEnd = addDays(drag.originalEnd, deltaDays)
if (newEnd <= drag.originalStart) newEnd = addDays(drag.originalStart, 1)
}
dragState.value = { ...drag, currentStart: newStart, currentEnd: newEnd }
}
async function handleDragEnd() {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
const drag = dragState.value
dragState.value = null
if (!drag) return
const changed = daysBetween(drag.originalStart, drag.currentStart) !== 0 || daysBetween(drag.originalEnd, drag.currentEnd) !== 0
if (!changed) {
// No movement - treat as a click on the bar body
if (drag.mode === 'move') openTask(drag.taskId)
return
}
const task = tasks.value.find(t => t.id === drag.taskId)
if (!task) return
const previousStart = task.start_date
const previousDeadline = task.deadline
const newStartStr = toDateString(drag.currentStart)
const newEndStr = toDateString(drag.currentEnd)
task.start_date = newStartStr
task.deadline = newEndStr
try {
await taskService.updateTask(drag.taskId, { start_date: newStartStr, deadline: newEndStr })
} catch (err: any) {
console.error('Failed to reschedule task:', err)
task.start_date = previousStart
task.deadline = previousDeadline
toast({
title: 'Error',
description: err.response?.data?.detail || 'Failed to reschedule task',
variant: 'destructive'
})
}
}
function taskBarStyle(task: TaskListItem) {
const isDragging = dragState.value?.taskId === task.id
const start = isDragging ? dragState.value!.currentStart : parseDate(task.start_date!)
const end = isDragging ? dragState.value!.currentEnd : parseDate(task.deadline!)
const left = dateToLeft(start)
const width = Math.max(dateToLeft(end) - left, 6)
return {
left: `${left}px`,
width: `${width}px`,
backgroundColor: statusColor(task)
}
}
function taskBarTitle(task: TaskListItem): string {
return `${task.name}: ${formatDate(task.start_date)} ${formatDate(task.deadline)}`
}
const legendStatuses = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId) || [])
function openTask(taskId: number) {
const task = tasks.value.find(t => t.id === taskId)
if (task) selectTask(task)
}
onMounted(() => {
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
})
watch(() => props.projectId, () => {
episodeFilter.value = null
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
</script>
<style scoped>
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
</style>
@@ -2,9 +2,9 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Custom Task Types</h3>
<h3 class="text-lg font-semibold">Task Types</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom task types beyond the standard types to adapt the pipeline to your project needs
Add task types beyond the standard types, either general-purpose or owned by a specific department, to adapt the pipeline to your project needs
</p>
</div>
@@ -30,33 +30,30 @@
<!-- Asset Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="assetTaskTypes.length > 0">
<template v-if="assetTaskTypeRows.length > 0">
<div
v-for="taskType in assetTaskTypes"
:key="taskType"
v-for="row in assetTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('asset', taskType)"
@click="openEditDialog('asset', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('asset', taskType)"
@click="handleDelete('asset', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -86,33 +83,30 @@
<!-- Shot Task Types List -->
<div class="border rounded-lg divide-y">
<template v-if="shotTaskTypes.length > 0">
<template v-if="shotTaskTypeRows.length > 0">
<div
v-for="taskType in shotTaskTypes"
:key="taskType"
v-for="row in shotTaskTypeRows"
:key="row.name"
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
Standard
</Badge>
<Badge v-else variant="outline">
Custom
</Badge>
<span class="font-medium capitalize">{{ formatTaskTypeName(row.name) }}</span>
<Badge v-if="row.isStandard" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge v-if="row.department" variant="outline" class="capitalize">{{ formatTaskTypeName(row.department) }}</Badge>
</div>
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
<div v-if="!row.isStandard" class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="openEditDialog('shot', taskType)"
@click="openEditDialog('shot', row.name, row.department)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="handleDelete('shot', taskType)"
@click="handleDelete('shot', row.name, row.department)"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
@@ -155,7 +149,25 @@
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
3-50 characters, lowercase alphanumeric with underscores only
{{ dialogDepartment ? '2-50' : '3-50' }} characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Department (optional)</Label>
<Select :model-value="dialogDepartment || 'none'" @update:model-value="(value) => { dialogDepartment = value === 'none' ? '' : (value as string); validateTaskTypeName() }">
<SelectTrigger>
<SelectValue placeholder="None (general purpose)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (general purpose)</SelectItem>
<SelectItem v-for="department in dialogCustomDepartments" :key="department.name" :value="department.name">
{{ formatTaskTypeName(department.name) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Assign this task type to a custom department so it's owned by that department, or leave it general-purpose.
</p>
</div>
</div>
@@ -181,6 +193,7 @@
// Only clear values when dialog closes and we're not in the middle of deleting
taskTypeToDelete = ''
categoryToDelete = ''
departmentToDelete = ''
deleteError = ''
}
}"
@@ -218,6 +231,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
@@ -237,6 +251,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
import { departmentService, type DepartmentInfo } from '@/services/department'
import { useDepartmentsStore } from '@/stores/departments'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
@@ -250,6 +266,13 @@ const emit = defineEmits<{
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
interface TaskTypeRow {
name: string
isStandard: boolean
department?: string
}
// State
const isLoading = ref(true)
@@ -259,6 +282,8 @@ const taskTypes = ref<AllTaskTypesResponse | null>(null)
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const dialogCategory = ref<'asset' | 'shot'>('asset')
const dialogDepartment = ref('')
const editingDepartment = ref('')
const taskTypeName = ref('')
const originalTaskTypeName = ref('')
const validationError = ref('')
@@ -268,6 +293,7 @@ const isSaving = ref(false)
const isDeleteDialogOpen = ref(false)
const taskTypeToDelete = ref('')
const categoryToDelete = ref<'asset' | 'shot'>('asset')
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
@@ -277,15 +303,52 @@ const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartmentNames = computed(() => (departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || []).map(d => d.name))
const assetDepartments = computed(() => allDepartments.value.filter(d => d.type === 'asset'))
const shotDepartments = computed(() => allDepartments.value.filter(d => d.type === 'shot'))
const dialogCustomDepartments = computed(() => {
const departments = dialogCategory.value === 'asset' ? assetDepartments.value : shotDepartments.value
return departments.filter(d => !standardDepartmentNames.value.includes(d.name))
})
function buildRows(flatAll: string[], flatStandard: string[], departments: DepartmentInfo[]): TaskTypeRow[] {
const rows = new Map<string, TaskTypeRow>()
for (const t of flatAll) {
rows.set(t, { name: t, isStandard: flatStandard.includes(t) })
}
for (const department of departments) {
const isDeptStandard = standardDepartmentNames.value.includes(department.name)
for (const t of department.task_types) {
const existing = rows.get(t)
if (existing) {
existing.department = department.name
existing.isStandard = existing.isStandard || isDeptStandard
} else {
rows.set(t, { name: t, isStandard: isDeptStandard, department: department.name })
}
}
}
return Array.from(rows.values())
}
const assetTaskTypeRows = computed(() => buildRows(assetTaskTypes.value, standardAssetTypes.value, assetDepartments.value))
const shotTaskTypeRows = computed(() => buildRows(shotTaskTypes.value, standardShotTypes.value, shotDepartments.value))
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 3 && !validationError.value
const minLength = dialogDepartment.value ? 2 : 3
return taskTypeName.value.length >= minLength && !validationError.value
})
// Methods
const loadTaskTypes = async () => {
try {
isLoading.value = true
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
const [types] = await Promise.all([
customTaskTypeService.getAllTaskTypes(props.projectId),
departmentsStore.fetchProjectDepartments(props.projectId, true)
])
taskTypes.value = types
} catch (error: any) {
console.error('Failed to load task types:', error)
toast({
@@ -298,28 +361,21 @@ const loadTaskTypes = async () => {
}
}
const isStandardAssetType = (taskType: string): boolean => {
return standardAssetTypes.value.includes(taskType)
}
const isStandardShotType = (taskType: string): boolean => {
return standardShotTypes.value.includes(taskType)
}
const formatTaskTypeName = (taskType: string): string => {
return taskType.replace(/_/g, ' ')
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
const minLength = dialogDepartment.value ? 2 : 3
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 3) {
validationError.value = 'Task type name must be at least 3 characters'
if (name.length < minLength) {
validationError.value = `Task type name must be at least ${minLength} characters`
return
}
@@ -333,10 +389,10 @@ const validateTaskTypeName = () => {
return
}
// Check for duplicates (only if adding or changing name)
// Check for duplicates against the merged (flat + department) rows for this category
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
if (existingTypes.includes(name)) {
const existingRows = dialogCategory.value === 'asset' ? assetTaskTypeRows.value : shotTaskTypeRows.value
if (existingRows.some(row => row.name === name)) {
validationError.value = 'A task type with this name already exists'
return
}
@@ -348,15 +404,17 @@ const validateTaskTypeName = () => {
const openAddDialog = (category: 'asset' | 'shot') => {
dialogMode.value = 'add'
dialogCategory.value = category
dialogDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const openEditDialog = (category: 'asset' | 'shot', taskType: string, department?: string) => {
dialogMode.value = 'edit'
dialogCategory.value = category
editingDepartment.value = department || ''
taskTypeName.value = taskType
originalTaskTypeName.value = taskType
validationError.value = ''
@@ -365,6 +423,8 @@ const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
const closeDialog = () => {
isDialogOpen.value = false
dialogDepartment.value = ''
editingDepartment.value = ''
taskTypeName.value = ''
originalTaskTypeName.value = ''
validationError.value = ''
@@ -381,17 +441,30 @@ const handleDialogSave = async () => {
isSaving.value = true
if (dialogMode.value === 'add') {
if (dialogDepartment.value) {
const response = await departmentService.addDepartmentTaskType(props.projectId, dialogDepartment.value, taskTypeName.value.trim())
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
task_type: taskTypeName.value.trim(),
category: dialogCategory.value
})
console.log('Add task type response:', response)
taskTypes.value = response
}
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
} else {
if (editingDepartment.value) {
const response = await departmentService.renameDepartmentTaskType(
props.projectId,
editingDepartment.value,
originalTaskTypeName.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.updateCustomTaskType(
props.projectId,
@@ -402,8 +475,8 @@ const handleDialogSave = async () => {
category: dialogCategory.value
}
)
console.log('Update task type response:', response)
taskTypes.value = response
}
toast({
title: 'Success',
@@ -427,22 +500,15 @@ const handleDialogSave = async () => {
}
}
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
console.log('=== HANDLE DELETE ===')
console.log('Received taskType:', taskType)
console.log('Received category:', category)
const handleDelete = (category: 'asset' | 'shot', taskType: string, department?: string) => {
taskTypeToDelete.value = taskType
categoryToDelete.value = category
departmentToDelete.value = department || ''
deleteError.value = ''
isDeleteDialogOpen.value = true
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
}
const closeDeleteDialog = () => {
console.log('=== CLOSE DELETE DIALOG ===')
isDeleteDialogOpen.value = false
// Values will be cleared by @update:open handler
}
@@ -451,36 +517,29 @@ const confirmDelete = async () => {
// Capture values immediately before any async operations
const taskTypeToDeleteLocal = taskTypeToDelete.value
const categoryToDeleteLocal = categoryToDelete.value
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
deleteError.value = ''
console.log('=== DELETE DEBUG ===')
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
console.log('categoryToDelete.value:', categoryToDeleteLocal)
console.log('projectId:', props.projectId)
if (!taskTypeToDeleteLocal) {
console.error('ERROR: taskTypeToDelete is empty!')
deleteError.value = 'Task type name is missing. Please try again.'
isDeleting.value = false
return
}
console.log('Deleting task type:', {
projectId: props.projectId,
taskType: taskTypeToDeleteLocal,
category: categoryToDeleteLocal
})
if (departmentToDeleteLocal) {
const response = await departmentService.removeDepartmentTaskType(props.projectId, departmentToDeleteLocal, taskTypeToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
} else {
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
console.log('Delete task type response:', response)
taskTypes.value = response
}
toast({
title: 'Success',
@@ -492,16 +551,20 @@ const confirmDelete = async () => {
// Close dialog and clear values
isDeleteDialogOpen.value = false
taskTypeToDelete.value = ''
categoryToDelete.value = ''
categoryToDelete.value = 'asset'
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
const detail = errorData?.detail
if (errorData?.task_count) {
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
if (detail?.task_count) {
deleteError.value = `Cannot delete: ${detail.task_count} task(s) are using this type`
} else if (typeof detail === 'string') {
deleteError.value = detail
} else {
deleteError.value = errorData?.detail || 'Failed to delete task type'
deleteError.value = 'Failed to delete task type'
}
toast({
@@ -0,0 +1,672 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h3 class="text-lg font-semibold">Departments</h3>
<p class="text-sm text-muted-foreground mt-1">
Add custom departments beyond the standard ones. Departments are used on tasks and team member assignments.
</p>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Content -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-muted-foreground" />
<h4 class="font-semibold">All Departments</h4>
</div>
<Button size="sm" @click="openAddDialog">
<Plus class="h-4 w-4 mr-2" />
Add Department
</Button>
</div>
<div class="border rounded-lg divide-y">
<template v-if="allDepartments.length > 0">
<div
v-for="department in allDepartments"
:key="department.name"
class="p-3 hover:bg-muted/50 transition-colors space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium capitalize">{{ formatName(department.name) }}</span>
<Badge v-if="isStandardDepartment(department.name)" variant="secondary">Standard</Badge>
<Badge v-else variant="outline">Custom</Badge>
<Badge variant="outline" class="capitalize">{{ department.type }}</Badge>
</div>
<div v-if="!isStandardDepartment(department.name)" class="flex items-center gap-2">
<Button size="sm" variant="ghost" @click="openEditDialog(department.name)">
<Pencil class="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" @click="handleDelete(department.name)">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 pl-1">
<Badge
v-for="taskType in department.task_types"
:key="taskType"
variant="secondary"
class="text-xs font-normal capitalize gap-1"
>
{{ formatName(taskType) }}
<button
v-if="!isStandardDepartment(department.name)"
class="hover:text-destructive"
@click="handleDeleteTaskType(department.name, taskType)"
>
<X class="h-3 w-3" />
</button>
</Badge>
<span v-if="department.task_types.length === 0" class="text-xs text-muted-foreground">
No task types defined
</span>
<Button
v-if="!isStandardDepartment(department.name)"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
@click="openAddTaskTypeDialog(department.name)"
>
<Plus class="h-3 w-3 mr-1" />
Add Task Type
</Button>
</div>
</div>
</template>
<div v-else class="p-4 text-center text-sm text-muted-foreground">
No departments defined
</div>
</div>
</div>
<!-- Add/Edit Department Dialog -->
<Dialog :open="isDialogOpen" @update:open="closeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} Department
</DialogTitle>
<DialogDescription>
{{ dialogMode === 'add'
? 'Enter a name for the new department. Use lowercase letters, numbers, and underscores only.'
: 'Update the department name. This will update all team members and tasks using this department.'
}}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="departmentName">Department Name</Label>
<Input
id="departmentName"
v-model="departmentName"
placeholder="e.g., fx, previz, matchmove"
:class="{ 'border-destructive': validationError }"
@input="validateDepartmentName"
/>
<p v-if="validationError" class="text-sm text-destructive">
{{ validationError }}
</p>
<p v-else class="text-sm text-muted-foreground">
2-50 characters, lowercase alphanumeric with underscores only
</p>
</div>
<div v-if="dialogMode === 'add'" class="space-y-2">
<Label>Type</Label>
<Select v-model="departmentType">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shot">Shot</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
Shot departments apply to shot tasks; asset departments apply to asset tasks.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeDialog">
Cancel
</Button>
<Button @click="handleDialogSave" :disabled="!isDepartmentNameValid || isSaving">
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Add Task Type Dialog -->
<Dialog :open="isTaskTypeDialogOpen" @update:open="closeTaskTypeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Add Task Type to "{{ formatName(taskTypeDepartment) }}"</DialogTitle>
<DialogDescription>
Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="taskTypeName">Task Type Name</Label>
<Input
id="taskTypeName"
v-model="taskTypeName"
placeholder="e.g., blocking, first_pass"
:class="{ 'border-destructive': taskTypeValidationError }"
@input="validateTaskTypeName"
/>
<p v-if="taskTypeValidationError" class="text-sm text-destructive">
{{ taskTypeValidationError }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeTaskTypeDialog">
Cancel
</Button>
<Button @click="handleAddTaskType" :disabled="!isTaskTypeNameValid || isSavingTaskType">
<div v-if="isSavingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Department Confirmation Dialog -->
<AlertDialog
:open="isDeleteDialogOpen"
@update:open="(open) => {
isDeleteDialogOpen = open
if (!open && !isDeleting) {
departmentToDelete = ''
deleteError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Department</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the department "{{ departmentToDelete }}"?
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
{{ deleteError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDelete"
:disabled="isDeleting"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Delete Task Type Confirmation Dialog -->
<AlertDialog
:open="isDeleteTaskTypeDialogOpen"
@update:open="(open) => {
isDeleteTaskTypeDialogOpen = open
if (!open && !isDeletingTaskType) {
taskTypeToDelete = null
deleteTaskTypeError = ''
}
}"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the task type "{{ taskTypeToDelete?.taskType }}" from "{{ taskTypeToDelete?.department }}"?
<span v-if="deleteTaskTypeError" class="block mt-2 text-destructive font-medium">
{{ deleteTaskTypeError }}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
@click="confirmDeleteTaskType"
:disabled="isDeletingTaskType"
class="bg-destructive hover:bg-destructive/90"
>
<div v-if="isDeletingTaskType" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Users, Plus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useDepartmentsStore } from '@/stores/departments'
import { departmentService, type DepartmentType } from '@/services/department'
import { useToast } from '@/components/ui/toast/use-toast'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
updated: []
}>()
const { toast } = useToast()
const departmentsStore = useDepartmentsStore()
// State
const isLoading = ref(true)
// Department dialog state
const isDialogOpen = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const departmentName = ref('')
const departmentType = ref<DepartmentType | ''>('')
const originalDepartmentName = ref('')
const validationError = ref('')
const isSaving = ref(false)
// Task type dialog state
const isTaskTypeDialogOpen = ref(false)
const taskTypeDepartment = ref('')
const taskTypeName = ref('')
const taskTypeValidationError = ref('')
const isSavingTaskType = ref(false)
// Delete department dialog state
const isDeleteDialogOpen = ref(false)
const departmentToDelete = ref('')
const deleteError = ref('')
const isDeleting = ref(false)
// Delete task type dialog state
const isDeleteTaskTypeDialogOpen = ref(false)
const taskTypeToDelete = ref<{ department: string; taskType: string } | null>(null)
const deleteTaskTypeError = ref('')
const isDeletingTaskType = ref(false)
// Computed
const allDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.departments || [])
const standardDepartments = computed(() => departmentsStore.getProjectDepartments(props.projectId)?.standard_departments || [])
const allDepartmentNames = computed(() => allDepartments.value.map(d => d.name))
const isDepartmentNameValid = computed(() => {
return departmentName.value.length >= 2 && !validationError.value && (dialogMode.value === 'edit' || !!departmentType.value)
})
const isTaskTypeNameValid = computed(() => {
return taskTypeName.value.length >= 2 && !taskTypeValidationError.value
})
// Methods
const loadDepartments = async () => {
try {
isLoading.value = true
await departmentsStore.fetchProjectDepartments(props.projectId, true)
} catch (error: any) {
console.error('Failed to load departments:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load departments',
variant: 'destructive'
})
} finally {
isLoading.value = false
}
}
const isStandardDepartment = (department: string): boolean => {
return standardDepartments.value.some(d => d.name === department)
}
const formatName = (name: string): string => {
return name.replace(/_/g, ' ')
}
const validateDepartmentName = () => {
const name = departmentName.value.trim()
if (name.length === 0) {
validationError.value = ''
return
}
if (name.length < 2) {
validationError.value = 'Department name must be at least 2 characters'
return
}
if (name.length > 50) {
validationError.value = 'Department name must be at most 50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
validationError.value = 'Department name must be lowercase alphanumeric with underscores only'
return
}
if (dialogMode.value === 'add' || name !== originalDepartmentName.value) {
if (allDepartmentNames.value.includes(name)) {
validationError.value = 'A department with this name already exists'
return
}
}
validationError.value = ''
}
const validateTaskTypeName = () => {
const name = taskTypeName.value.trim()
if (name.length === 0) {
taskTypeValidationError.value = ''
return
}
if (name.length < 2 || name.length > 50) {
taskTypeValidationError.value = 'Task type name must be 2-50 characters'
return
}
if (!/^[a-z0-9_]+$/.test(name)) {
taskTypeValidationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
return
}
const department = allDepartments.value.find(d => d.name === taskTypeDepartment.value)
if (department?.task_types.includes(name)) {
taskTypeValidationError.value = 'A task type with this name already exists in this department'
return
}
taskTypeValidationError.value = ''
}
const openAddDialog = () => {
dialogMode.value = 'add'
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
isDialogOpen.value = true
}
const openEditDialog = (department: string) => {
dialogMode.value = 'edit'
departmentName.value = department
originalDepartmentName.value = department
validationError.value = ''
isDialogOpen.value = true
}
const closeDialog = () => {
isDialogOpen.value = false
departmentName.value = ''
departmentType.value = ''
originalDepartmentName.value = ''
validationError.value = ''
}
const openAddTaskTypeDialog = (department: string) => {
taskTypeDepartment.value = department
taskTypeName.value = ''
taskTypeValidationError.value = ''
isTaskTypeDialogOpen.value = true
}
const closeTaskTypeDialog = () => {
isTaskTypeDialogOpen.value = false
taskTypeDepartment.value = ''
taskTypeName.value = ''
taskTypeValidationError.value = ''
}
const handleDialogSave = async () => {
validateDepartmentName()
if (!isDepartmentNameValid.value) {
return
}
try {
isSaving.value = true
if (dialogMode.value === 'add') {
const response = await departmentService.addDepartment(props.projectId, {
department: departmentName.value.trim(),
department_type: departmentType.value as DepartmentType
})
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentName.value}" added successfully`
})
} else {
const response = await departmentService.updateDepartment(
props.projectId,
originalDepartmentName.value,
{
old_name: originalDepartmentName.value,
new_name: departmentName.value.trim()
}
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department updated successfully`
})
}
emit('updated')
closeDialog()
} catch (error: any) {
console.error('Failed to save department:', error)
const errorMessage = error.response?.data?.detail || 'Failed to save department'
validationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSaving.value = false
}
}
const handleAddTaskType = async () => {
validateTaskTypeName()
if (!isTaskTypeNameValid.value) {
return
}
try {
isSavingTaskType.value = true
const response = await departmentService.addDepartmentTaskType(
props.projectId,
taskTypeDepartment.value,
taskTypeName.value.trim()
)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${taskTypeName.value}" added successfully`
})
emit('updated')
closeTaskTypeDialog()
} catch (error: any) {
console.error('Failed to add task type:', error)
const errorMessage = error.response?.data?.detail || 'Failed to add task type'
taskTypeValidationError.value = errorMessage
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
})
} finally {
isSavingTaskType.value = false
}
}
const handleDelete = (department: string) => {
departmentToDelete.value = department
deleteError.value = ''
isDeleteDialogOpen.value = true
}
const confirmDelete = async () => {
const departmentToDeleteLocal = departmentToDelete.value
try {
isDeleting.value = true
deleteError.value = ''
if (!departmentToDeleteLocal) {
deleteError.value = 'Department name is missing. Please try again.'
isDeleting.value = false
return
}
const response = await departmentService.deleteDepartment(props.projectId, departmentToDeleteLocal)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Department "${departmentToDeleteLocal}" deleted successfully`
})
emit('updated')
isDeleteDialogOpen.value = false
departmentToDelete.value = ''
deleteError.value = ''
} catch (error: any) {
console.error('Failed to delete department:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined || errorData?.detail?.member_count !== undefined) {
const { task_count, member_count } = errorData.detail
const parts = []
if (member_count) parts.push(`${member_count} team member(s)`)
if (task_count) parts.push(`${task_count} task(s)`)
deleteError.value = `Cannot delete: ${parts.join(' and ')} are using this department`
} else {
deleteError.value = errorData?.detail || 'Failed to delete department'
}
toast({
title: 'Error',
description: deleteError.value,
variant: 'destructive'
})
} finally {
isDeleting.value = false
}
}
const handleDeleteTaskType = (department: string, taskType: string) => {
taskTypeToDelete.value = { department, taskType }
deleteTaskTypeError.value = ''
isDeleteTaskTypeDialogOpen.value = true
}
const confirmDeleteTaskType = async () => {
const target = taskTypeToDelete.value
if (!target) return
try {
isDeletingTaskType.value = true
deleteTaskTypeError.value = ''
const response = await departmentService.removeDepartmentTaskType(props.projectId, target.department, target.taskType)
departmentsStore.updateProjectDepartments(props.projectId, response)
toast({
title: 'Success',
description: `Task type "${target.taskType}" deleted successfully`
})
emit('updated')
isDeleteTaskTypeDialogOpen.value = false
taskTypeToDelete.value = null
deleteTaskTypeError.value = ''
} catch (error: any) {
console.error('Failed to delete task type:', error)
const errorData = error.response?.data
if (errorData?.detail?.task_count !== undefined) {
deleteTaskTypeError.value = `Cannot delete: ${errorData.detail.task_count} task(s) are using this task type`
} else {
deleteTaskTypeError.value = errorData?.detail || 'Failed to delete task type'
}
toast({
title: 'Error',
description: deleteTaskTypeError.value,
variant: 'destructive'
})
} finally {
isDeletingTaskType.value = false
}
}
// Lifecycle
onMounted(() => {
loadDepartments()
})
</script>
@@ -100,7 +100,7 @@
</p>
<Button @click="showCreateDialog = true" v-if="canManage">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineEmits<{ clear: [] }>()
</script>
<template>
<Button variant="ghost" size="sm" class="h-8 px-2 lg:px-3" @click="$emit('clear')">
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import type { VisibilityState } from '@tanstack/vue-table'
export interface ColumnOption {
id: string
label: string
/** Optional section label; columns without one are grouped first, unlabeled. */
group?: string
}
interface Props {
columns: ColumnOption[]
modelValue: VisibilityState
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: VisibilityState]
}>()
const isVisible = (columnId: string) => props.modelValue[columnId] !== false
const toggle = (columnId: string, value: boolean) => {
emit('update:modelValue', { ...props.modelValue, [columnId]: value })
}
const hiddenCount = computed(() => props.columns.filter(col => !isVisible(col.id)).length)
const groupedColumns = computed(() => {
const groups = new Map<string | undefined, ColumnOption[]>()
for (const column of props.columns) {
const key = column.group
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(column)
}
return Array.from(groups.entries()).map(([label, columns]) => ({ label, columns }))
})
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<template v-for="group in groupedColumns" :key="group.label ?? '__default__'">
<CommandGroup>
<div v-if="group.label" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ group.label }}
</div>
<CommandSeparator v-if="group.label" />
<CheckableCommandItem
v-for="column in group.columns"
:key="column.id"
:value="column.id"
:model-value="isVisible(column.id)"
@update:model-value="(value) => toggle(column.id, value)"
>
<span>{{ column.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</template>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ title: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">{{ title }}</h3>
<p class="text-muted-foreground mb-4">{{ message }}</p>
<Button @click="$emit('retry')" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
interface Props {
title: string
deletedAt?: string | null
}
defineProps<Props>()
defineEmits<{ close: [] }>()
</script>
<template>
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': deletedAt }">{{ title }}</h2>
<slot name="badges" />
</div>
<Button variant="ghost" size="icon-sm" class="flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<div class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">{{ label }}</span>
</div>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Sheet, SheetContent } from '@/components/ui/sheet'
interface Props {
visible: boolean
mobileOpen: boolean
}
defineProps<Props>()
const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
</script>
<template>
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="visible"
class="fixed right-0 top-16 bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<slot />
</div>
</Transition>
<Sheet :open="mobileOpen" @update:open="emit('update:mobileOpen', $event)">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<slot />
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ enabled: boolean }>()
defineEmits<{ toggle: [] }>()
</script>
<template>
<Button
@click="$emit('toggle')"
:variant="enabled ? 'default' : 'outline'"
size="icon-sm"
:class="enabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="enabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="enabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col h-full">
<!-- Filter / Sort Toolbar -->
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-1.5">
<Popover v-if="tasks.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
Tasks
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
{{ taskFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-52 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="taskFilters.length === 0"
@update:model-value="taskFilters = []"
>
All Tasks
</CheckableCommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CheckableCommandItem
v-for="task in tasks"
:key="task.id"
:value="String(task.id)"
:model-value="taskFilters.includes(task.id)"
@update:model-value="toggleTaskFilter(task.id)"
>
{{ formatTaskType(task.task_type) }}
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
size="icon-sm"
@click="showSubmissionNotes = !showSubmissionNotes"
>
<Send class="h-3.5 w-3.5" />
<span class="sr-only">Submission notes</span>
</Button>
</TooltipTrigger>
<TooltipContent>Submission notes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showClientOnly ? 'secondary' : 'outline'"
size="icon-sm"
@click="showClientOnly = !showClientOnly"
>
<Megaphone class="h-3.5 w-3.5" />
<span class="sr-only">Client notes only</span>
</Button>
</TooltipTrigger>
<TooltipContent>Client notes only</TooltipContent>
</Tooltip>
</div>
<Select v-model="sortOrder">
<SelectTrigger class="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="combinedEntries.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
<p class="text-sm">No notes yet. Start the conversation below.</p>
</div>
<template v-for="entry in combinedEntries" :key="entry.id">
<!-- Production Note -->
<div v-if="entry.kind === 'note'" class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div>
<NoteItem
:note="entry.note"
:task-id="entry.note.task_id"
date-format="absolute"
hide-client-badge
@note-updated="emit('notesUpdated')"
@reply="handleReply"
/>
</div>
<!-- Submission Note (read-only) -->
<div v-else class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
</div>
<div class="flex gap-3">
<Avatar class="h-8 w-8 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
</span>
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
{{ formatDateOnly(entry.submission.submitted_at) }}
<Tooltip>
<TooltipTrigger as-child>
<Info class="h-3 w-3 cursor-help" />
</TooltipTrigger>
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
</Tooltip>
</span>
</div>
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
</div>
</div>
</div>
</template>
</div>
<!-- Note Input (Bottom) -->
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select task..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
type="button"
size="sm"
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
@click="newNoteType = 'internal'"
>
Internal
</Button>
<Button
type="button"
size="sm"
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
@click="newNoteType = 'client'"
>
Client
</Button>
</div>
<Button
size="sm"
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
@click="handleAddNote"
>
<MessageSquarePlus class="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import NoteItem from '@/components/task/NoteItem.vue'
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface EntityNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: EntityNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
const { toast } = useToast()
const { isCoordinatorOrAdmin } = usePermission()
const { getAvatarUrl } = useAvatarUrl()
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const taskFilters = ref<number[]>([])
const sortOrder = ref<'newest' | 'oldest'>('newest')
const showSubmissionNotes = ref(true)
const showClientOnly = ref(false)
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const targetTaskId = ref<number | null>(null)
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
watch(() => props.tasks, (tasks) => {
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
targetTaskId.value = tasks[0]?.id ?? null
}
}, { immediate: true })
function toggleTaskFilter(taskId: number) {
const index = taskFilters.value.indexOf(taskId)
if (index > -1) taskFilters.value.splice(index, 1)
else taskFilters.value.push(taskId)
}
function isFilterActive(taskId: number): boolean {
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
}
function taskTypeFor(taskId: number): string {
return props.tasks.find(t => t.id === taskId)?.task_type || ''
}
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function formatDateOnly(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
}
function formatDateTimeFull(dateString: string): string {
const date = new Date(dateString)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
interface NoteEntry {
kind: 'note'
id: string
date: string
note: ProductionNote
}
interface SubmissionEntry {
kind: 'submission'
id: string
date: string
submission: Submission
}
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
const noteEntries: NoteEntry[] = props.notes
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
? props.submissions
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
: []
const direction = sortOrder.value === 'newest' ? -1 : 1
return [...noteEntries, ...submissionEntries].sort(
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
)
})
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
function handleReply(noteId: number) {
const note = findNote(props.notes, noteId)
if (!note) return
replyToNoteId.value = noteId
targetTaskId.value = note.task_id
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
async function handleAddNote() {
if (!newNoteContent.value.trim() || !targetTaskId.value) return
submitting.value = true
try {
await taskService.createTaskNote(
targetTaskId.value,
newNoteContent.value,
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
title: 'Success',
description: 'Note added successfully'
})
} catch (error: any) {
console.error('Error adding note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to add note',
variant: 'destructive'
})
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
export interface SegmentOption {
value: string
label?: string
icon?: unknown
title?: string
}
interface Props {
options: SegmentOption[]
modelValue: string
}
defineProps<Props>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
v-for="option in options"
:key="option.value"
variant="ghost"
size="sm"
:class="{ 'bg-muted': modelValue === option.value }"
:title="option.title"
class="h-7 px-2"
@click="$emit('update:modelValue', option.value)"
>
<component :is="option.icon" v-if="option.icon" class="h-4 w-4" />
<template v-if="option.label">{{ option.label }}</template>
</Button>
</div>
</template>
@@ -1,3 +1,95 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
allTaskTypes: string[]
projectId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'filter-changed': [filter: string]
}>()
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// System status options (fallback if no project ID / statuses not loaded yet)
const defaultStatusOptions = [
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
{ id: 'approved', name: 'Approved', color: '', is_system: true },
{ id: 'retake', name: 'Retake', color: '', is_system: true },
]
const allStatuses = computed(() => {
if (!props.projectId) return defaultStatusOptions
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) return defaultStatusOptions
const systemStatusList = statusData.system_statuses.map(s => ({
id: s.id, name: s.name, color: s.color, is_system: s.is_system,
}))
const customStatusList = statusData.statuses.map(s => ({
id: s.id, name: s.name, color: s.color, is_system: false,
}))
return [...systemStatusList, ...customStatusList]
})
const loadStatuses = async () => {
if (!props.projectId) return
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to load task statuses:', error)
}
}
onMounted(loadStatuses)
watch(() => props.projectId, loadStatuses)
const toggleFilter = (filter: string) => {
const index = selectedFilters.value.indexOf(filter)
if (index > -1) {
selectedFilters.value.splice(index, 1)
} else {
selectedFilters.value.push(filter)
}
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
emit('filter-changed', apiFilter)
}
const clearAllFilters = () => {
selectedFilters.value = []
emit('filter-changed', '')
}
const formatTaskType = (taskType: string) =>
taskType.charAt(0).toUpperCase() + taskType.slice(1)
</script>
<template>
<Popover>
<PopoverTrigger as-child>
@@ -21,184 +113,35 @@
<!-- All Tasks Option -->
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="clearAllFilters"
:model-value="selectedFilters.length === 0"
@update:model-value="clearAllFilters"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.length === 0
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Tasks</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup
v-for="taskType in allTaskTypes"
:key="taskType"
>
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CommandItem
<CheckableCommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
@select="toggleFilter(`${taskType}:${status.id}`)"
:model-value="selectedFilters.includes(`${taskType}:${status.id}`)"
@update:model-value="toggleFilter(`${taskType}:${status.id}`)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.includes(`${taskType}:${status.id}`)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter, Check } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
allTaskTypes: string[]
projectId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'filter-changed': [filter: string]
}>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// Get loading state from store
const isLoading = computed(() => {
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
})
// System status options (fallback if no project ID)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
]
// Combine system and custom statuses
const allStatuses = computed(() => {
if (!props.projectId) {
return defaultStatusOptions
}
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) {
return defaultStatusOptions
}
// Convert system statuses to the format expected by TaskStatusBadge
const systemStatusList = statusData.system_statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: status.is_system
}))
// Convert custom statuses to the format expected by TaskStatusBadge
const customStatusList = statusData.statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: false
}))
return [...systemStatusList, ...customStatusList]
})
// Load custom statuses when component mounts or projectId changes
const loadStatuses = async () => {
if (!props.projectId) {
return
}
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to load task statuses:', error)
}
}
onMounted(() => {
loadStatuses()
})
watch(() => props.projectId, () => {
loadStatuses()
})
const toggleFilter = (filter: string) => {
const index = selectedFilters.value.indexOf(filter)
if (index > -1) {
selectedFilters.value.splice(index, 1)
} else {
selectedFilters.value.push(filter)
}
// Emit the filters as a comma-separated string, or empty string if none selected
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
emit('filter-changed', apiFilter)
}
const clearAllFilters = () => {
selectedFilters.value = []
emit('filter-changed', '')
}
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
}
</script>
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import type { ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useProjectMembersStore } from '@/stores/projectMembers'
interface StatusOption {
id: string
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const projectMembersStore = useProjectMembersStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
const searchQuery = ref('')
// Filtered project members based on search query
@@ -306,27 +308,18 @@ const fetchStatuses = async () => {
}
}
// Load project members
// Load project members (shared cache across all EditableTaskStatus instances for this project)
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
console.log('Loading project members for project:', props.projectId)
projectMembers.value = await projectService.getProjectMembers(props.projectId)
console.log('Loaded project members:', projectMembers.value)
await projectMembersStore.fetchProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
console.log('Ensuring project members are loaded')
if (projectMembers.value.length === 0) {
console.log('Loading project members on button click')
loadProjectMembers()
}
}
@@ -407,7 +400,6 @@ onMounted(() => {
// Refetch statuses when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
loadProjectMembers()
})
</script>
+85 -50
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">
<ShotTableToolbar
:view-mode="viewMode"
:episode-filter="episodeFilter"
@@ -14,12 +14,14 @@
:project-id="projectId"
:selected-shot="selectedShot"
:is-detail-panel-enabled="isDetailPanelEnabled"
:is-columns-locked="lockColumns"
@update:view-mode="viewMode = $event"
@update:episode-filter="handleEpisodeFilterChange"
@update:search="searchQuery = $event"
@update:column-visibility="handleColumnVisibilityChange"
@task-status-filter-changed="handleTaskStatusFilter"
@toggle-detail-panel="toggleDetailPanelEnabled"
@toggle-column-lock="toggleColumnLock"
@bulk-create="showBulkCreateDialog = true"
@create-shot="showCreateDialog = true"
/>
@@ -76,11 +78,11 @@
</div>
<!-- Shots Grid/List/Table -->
<div v-else>
<div v-else class="flex-1 min-h-0">
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6 h-full overflow-auto"
>
<ShotCard
v-for="shot in filteredShots"
@@ -93,7 +95,7 @@
</div>
<!-- List View -->
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6">
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6 h-full overflow-auto">
<div
v-for="shot in filteredShots"
:key="shot.id"
@@ -118,7 +120,7 @@
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -148,55 +150,43 @@
:sorting="sorting"
:column-visibility="columnVisibility"
:all-task-types="allTaskTypes"
:lock-columns="lockColumns"
@update:sorting="sorting = $event"
@update:column-visibility="handleColumnVisibilityChange"
@update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick"
@row-dblclick="handleRowDoubleClick"
/>
</div>
</div>
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
<!-- Detail Panel (Overlay - Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedShot)"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
:initial-reply-note-id="selectedTaskReplyNoteId"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel
v-else-if="selectedShot"
:project-id="projectId"
:shot-id="selectedShot.id"
:initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot"
@delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
</DetailPanelOverlay>
</div>
</Transition>
</div>
<!-- Mobile Detail Sheet -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<ShotDetailPanel
v-if="selectedShot"
:project-id="projectId"
:shot-id="selectedShot.id"
@edit="editShot"
@delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
/>
</SheetContent>
</Sheet>
<!-- Dialogs -->
<div class="space-y-4">
@@ -278,6 +268,7 @@
<script setup lang="ts">
import { ref, computed, watch, shallowRef, markRaw, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
Search, Plus, Camera, AlertCircle, RefreshCw,
Layers, MoreHorizontal, Edit, ListTodo, Trash2
@@ -293,10 +284,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Sheet,
SheetContent,
} from '@/components/ui/sheet'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
import {
DropdownMenu,
@@ -309,6 +297,7 @@ import ShotCard from './ShotCard.vue'
import ShotForm from './ShotForm.vue'
import BulkShotForm from './BulkShotForm.vue'
import ShotDetailPanel from './ShotDetailPanel.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import ShotsDataTable from './ShotsDataTable.vue'
import ShotTableToolbar from './ShotTableToolbar.vue'
import { createShotColumns, type ShotColumnMeta } from './columns'
@@ -336,6 +325,7 @@ interface Props {
const props = defineProps<Props>()
// Composables
const router = useRouter()
const { toast } = useToast()
const taskStatusesStore = useTaskStatusesStore()
@@ -355,6 +345,13 @@ const {
sessionStorageKey: 'shotBrowser.detailPanelEnabled'
})
// Handle double-click to navigate to shot detail page
const handleRowDoubleClick = (shot: Shot) => {
if (props.projectId) {
router.push(`/projects/${props.projectId}/shots/${shot.id}`)
}
}
// Reactive state
const shots = ref<Shot[]>([])
const isLoading = ref(false)
@@ -383,6 +380,13 @@ const columnVisibilityStore = useColumnVisibilityStore()
const columnVisibility = ref<VisibilityState>({})
const rowSelection = ref<Record<string, boolean>>({})
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
const lockColumns = ref(localStorage.getItem('shot-columns-locked') === 'true')
const toggleColumnLock = () => {
lockColumns.value = !lockColumns.value
localStorage.setItem('shot-columns-locked', String(lockColumns.value))
}
// Computed for selected count
const selectedCount = computed(() => {
return Object.keys(rowSelection.value).length
@@ -430,6 +434,9 @@ const loadShots = async () => {
if (taskStatusFilter.value) {
options.taskStatusFilter = taskStatusFilter.value
}
if (episodeFilter.value !== null) {
options.episodeId = episodeFilter.value
}
const data = await shotService.getShots(options)
shots.value = data
@@ -449,6 +456,11 @@ const loadEpisodes = async () => {
episodes.value = data
} catch (err) {
console.error('Failed to load episodes:', err)
toast({
title: 'Failed to load episodes',
description: err instanceof Error ? err.message : 'Episode filtering may be unavailable',
variant: 'destructive'
})
}
}
@@ -460,6 +472,11 @@ const loadTaskTypes = async () => {
allTaskTypes.value = data.shot_task_types || []
} catch (err) {
console.error('Failed to load task types:', err)
toast({
title: 'Failed to load task types',
description: err instanceof Error ? err.message : 'Task columns may be unavailable',
variant: 'destructive'
})
}
}
@@ -470,6 +487,11 @@ const loadTaskStatuses = async () => {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (err) {
console.error('Failed to load task statuses:', err)
toast({
title: 'Failed to load task statuses',
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
variant: 'destructive'
})
}
}
@@ -481,6 +503,11 @@ const loadProjectContext = async () => {
projectContext.value = project
} catch (err) {
console.error('Failed to load project context:', err)
toast({
title: 'Failed to load project context',
description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable',
variant: 'destructive'
})
}
}
@@ -524,6 +551,7 @@ const clearValidationError = () => {
const handleEpisodeFilterChange = (episodeId: number | null) => {
episodeFilter.value = episodeId
loadShots()
}
const handleTaskStatusFilter = (filter: string) => {
@@ -819,15 +847,22 @@ const clearSearch = () => {
loadShots()
}
const handleCreateTask = () => {
// TODO: Navigate to task creation for this shot
console.log('Create task for shot:', selectedShot.value?.name)
const selectedTaskId = ref<number | null>(null)
const selectedTaskTab = ref<string>('infos')
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
selectedTaskId.value = task.id
selectedTaskTab.value = tab || 'infos'
selectedTaskReplyNoteId.value = noteId
}
const handleSelectTask = (task: any) => {
// TODO: Navigate to task detail view
console.log('View task:', task.name)
}
// Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => {
selectedTaskId.value = null
selectedTaskTab.value = 'infos'
selectedTaskReplyNoteId.value = undefined
})
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
+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>
@@ -1,159 +0,0 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Toggle columns">
<div class="flex items-center gap-2">
<Columns class="h-4 w-4" />
<span>Columns</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="toggle">Toggle Columns</SelectItem>
<SelectGroup>
<SelectLabel>Basic Columns</SelectLabel>
<SelectItem value="thumbnail" @click="toggleColumn('thumbnail')">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isColumnVisible('thumbnail')"
@change="handleCheckboxChange('thumbnail', $event)"
class="rounded border-gray-300"
/>
<span>Thumbnail</span>
</div>
</SelectItem>
<SelectItem value="name" @click="toggleColumn('name')">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isColumnVisible('name')"
@change="handleCheckboxChange('name', $event)"
class="rounded border-gray-300"
/>
<span>Shot Name</span>
</div>
</SelectItem>
<SelectItem value="episode" @click="toggleColumn('episode')">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isColumnVisible('episode')"
@change="handleCheckboxChange('episode', $event)"
class="rounded border-gray-300"
/>
<span>Episode</span>
</div>
</SelectItem>
<SelectItem value="status" @click="toggleColumn('status')">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isColumnVisible('status')"
@change="handleCheckboxChange('status', $event)"
class="rounded border-gray-300"
/>
<span>Status</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Task Status Columns</SelectLabel>
<SelectItem
v-for="taskType in allTaskTypes"
:key="taskType"
:value="taskType"
@click="toggleColumn(taskType)"
>
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isColumnVisible(taskType)"
@change="handleCheckboxChange(taskType, $event)"
class="rounded border-gray-300"
/>
<span>{{ formatTaskType(taskType) }}</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Columns } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { VisibilityState } from '@tanstack/vue-table'
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
interface Props {
allTaskTypes: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:columnVisibility': [visibility: VisibilityState]
}>()
// Use global store
const columnVisibilityStore = useColumnVisibilityStore()
// Local reactive state for checkbox visibility
const localVisibility = ref<Record<string, boolean>>({})
// Watch for store changes and update local state
watch(
() => columnVisibilityStore.columnVisibility,
(newVal) => {
localVisibility.value = { ...newVal }
},
{ immediate: true, deep: true }
)
// Initialize store on mount
onMounted(() => {
columnVisibilityStore.initialize()
})
const selectedColumn = ref('toggle')
const handleColumnToggle = (value: string) => {
// Reset selection after interaction
selectedColumn.value = 'toggle'
}
const isColumnVisible = (columnId: string): boolean => {
// If not in visibility state, column is visible by default
return localVisibility.value[columnId] !== false
}
const handleCheckboxChange = (column: string, event: Event) => {
const target = event.target as HTMLInputElement
updateColumn(column, target.checked)
}
const toggleColumn = (column: string) => {
updateColumn(column, !isColumnVisible(column))
}
const updateColumn = (column: string, checked: boolean) => {
// Always use global store
columnVisibilityStore.updateColumn(column, checked)
}
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
}
</script>
@@ -258,7 +258,7 @@ watch(() => props.open, (newOpen) => {
deletionInfo.value = null
loadError.value = null
}
})
}, { immediate: true })
const handleDelete = async () => {
if (!isConfirmed.value) return
+250 -181
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>
@@ -131,15 +128,58 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3>
<Button
v-if="canCreateTask"
size="sm"
variant="outline"
@click="$emit('create-task')"
>
<div class="flex items-center gap-1">
<Popover v-if="canCreateTask">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
<Plus class="h-3 w-3 mr-1" />
Add Task
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
All task types already added
</div>
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="type in availableTaskTypes"
:key="type"
variant="ghost"
size="sm"
class="justify-start"
:disabled="isCreatingTask"
@click="handleAddTask(type)"
>
{{ formatTaskType(type) }}
</Button>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon-sm" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'submissions')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- No Tasks -->
<div v-if="tasks.length === 0" class="text-center py-8">
@@ -147,57 +187,45 @@
<p class="text-sm text-muted-foreground">No tasks yet</p>
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
</div>
<!-- Tasks Table -->
<div v-else class="border rounded-lg overflow-hidden">
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
<div>Task Type</div>
<div>Assignee</div>
<div>Status</div>
</div>
<div
<!-- Tasks Cards -->
<div v-else class="space-y-1.5">
<Card
v-for="task in tasks"
:key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
@click="$emit('select-task', task)"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
@click="$emit('select-task', task, 'infos')"
>
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-sm text-muted-foreground">
{{ task.assigned_user_name || 'Unassigned' }}
</div>
<div>
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
{{ formatTaskStatus(task.status.toString()) }}
</Badge>
</div>
<span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
<div class="flex items-center gap-2 flex-shrink-0">
<Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
</Avatar>
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
</div>
</Card>
</div>
</div>
</TabsContent>
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Production Notes</h3>
<Button
v-if="canCreateNote"
size="sm"
variant="outline"
@click="$emit('create-note')"
>
<Plus class="h-3 w-3 mr-1" />
Add Note
</Button>
</div>
<div class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes...
</div>
<EntityNotes
v-else
:key="shotId"
:tasks="tasks"
:notes="shotNotes"
:submissions="shotSubmissions"
@notes-updated="loadShotNotes"
/>
</TabsContent>
<!-- Assets Tab -->
<TabsContent value="assets" class="flex-1 p-6">
<TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3>
<Button
@@ -219,18 +247,32 @@
</TabsContent>
<!-- References Tab -->
<TabsContent value="references" class="flex-1 p-6">
<TabsContent value="references" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3>
<Button
v-if="canUploadReferences"
size="sm"
variant="outline"
@click="$emit('upload-reference')"
>
<Popover v-if="canUploadReferences">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
<Plus class="h-3 w-3 mr-1" />
Upload Reference
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'attachments')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
@@ -241,7 +283,7 @@
</TabsContent>
<!-- Design Tab -->
<TabsContent value="design" class="flex-1 p-6">
<TabsContent value="design" class="flex-1 overflow-y-auto p-6 m-0">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3>
<Button
@@ -280,37 +322,52 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import EntityNotes from '@/components/shared/EntityNotes.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { useAuthStore } from '@/stores/auth'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote, type Submission } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDepartmentsStore } from '@/stores/departments'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { usePermission } from '@/composables/usePermission'
// Use TaskStatusInfo from shot service instead of local Task interface
interface Task extends TaskStatusInfo {
id: number
name?: string
assigned_user_name?: string
assigned_user_avatar_url?: string | null
assigned_user_first_name?: string
assigned_user_last_name?: string
deadline?: string
}
interface Props {
projectId: number
shotId: number
initialShot?: Shot
allTaskTypes: string[]
}
interface Emits {
(e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void
(e: 'create-task'): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
(e: 'link-asset'): void
(e: 'upload-reference'): void
(e: 'edit-design'): void
(e: 'close'): void
}
@@ -318,13 +375,21 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
const departmentsStore = useDepartmentsStore()
const { getAvatarUrl } = useAvatarUrl()
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
// Reactive state
const shot = ref<Shot | null>(null)
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const shotSubmissions = ref<Submission[]>([])
const isLoadingNotes = ref(false)
// Computed properties
const frameCount = computed(() => {
@@ -362,24 +427,25 @@ const taskStatusCounts = computed(() => {
const canCreateTask = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => {
return true // All users can upload references
})
const canEditDesign = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
// Task types offered in "Add Task" include the flat shot task type list
// plus every task type owned by a shot department (e.g. Animation's own
// task types), deduped against types the shot already has a task for.
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'shot')
.flatMap(d => d.task_types)
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
return merged.filter(type => !existingTypes.has(type))
})
// Methods
@@ -387,8 +453,14 @@ const loadShotDetails = async () => {
try {
isLoading.value = true
error.value = null
shot.value = await shotService.getShot(props.shotId)
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
departmentsStore.fetchProjectDepartments(props.projectId)
await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers()
])
loadTasks() // No longer async - uses embedded data
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err)
@@ -397,33 +469,76 @@ const loadShotDetails = async () => {
}
}
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
shotSubmissions.value = []
return
}
try {
isLoadingNotes.value = true
const [notesByTask, submissionsByTask] = await Promise.all([
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
])
shotNotes.value = notesByTask.flat()
shotSubmissions.value = submissionsByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
const loadProjectMembers = async () => {
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
} catch (err) {
console.error('Failed to load project members:', err)
}
}
const loadTasks = () => {
// Use task_details already embedded in shot data - no API call needed!
if (shot.value?.task_details) {
tasks.value = shot.value.task_details.map(taskInfo => ({
tasks.value = shot.value.task_details.map(taskInfo => {
const assignedUser = projectMembers.value.find(member => member.user_id === taskInfo.assigned_user_id)
return {
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// Add placeholder values for display compatibility
name: taskInfo.task_type, // Use task_type as name for display
assigned_user_name: undefined // Will be resolved if needed
}))
assigned_user_name: assignedUser ? `${assignedUser.user_first_name} ${assignedUser.user_last_name}` : undefined,
assigned_user_avatar_url: assignedUser?.user_avatar_url,
assigned_user_first_name: assignedUser?.user_first_name,
assigned_user_last_name: assignedUser?.user_last_name
}
})
} else {
tasks.value = []
}
}
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
// Resolve the project's actual status object (name + color) for a task, matching the shot table
const getTaskStatusObject = (task: Task) => {
const statusId = task.status.toString()
return taskStatusesStore.getStatusById(props.projectId, statusId) || statusId
}
const formatTaskStatus = (status: string) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createShotTask(props.shotId, taskType)
// Bypass initialShot (which would just return the stale cached object) to get the new task
shot.value = await shotService.getShot(props.shotId)
loadTasks()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatTaskType = (taskType: string) => {
@@ -432,56 +547,10 @@ const formatTaskType = (taskType: string) => {
).join(' ')
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const getTaskStatusVariant = (status: string | TaskStatus) => {
const statusStr = status.toString()
switch (statusStr) {
case 'not_started':
return 'secondary'
case 'in_progress':
return 'default'
case 'submitted':
return 'outline'
case 'approved':
return 'default'
case 'retake':
return 'destructive'
default:
return 'secondary'
}
const getTaskAssigneeInitials = (task: Task) => {
const first = task.assigned_user_first_name?.charAt(0) || ''
const last = task.assigned_user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const formatDate = (dateString: string) => {
+62 -171
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,32 +84,27 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
<DetailPanelToggleButton
v-if="viewMode === 'table'"
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
<Unlock v-else class="h-4 w-4" />
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -235,10 +121,10 @@
</div>
<!-- Action Buttons -->
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('bulk-create')" variant="outline" size="icon-sm">
<Layers class="h-4 w-4" />
</Button>
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-shot')" size="icon-sm" title="Create Shot">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -250,7 +136,7 @@
import { computed } from 'vue'
import {
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
ListTodo, ListX, Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -265,11 +151,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
import type { Shot } from '@/services/shot'
@@ -284,6 +174,7 @@ interface Props {
projectId: number
selectedShot: Shot | null
isDetailPanelEnabled: boolean
isColumnsLocked: boolean
}
const props = defineProps<Props>()
@@ -295,6 +186,7 @@ const emit = defineEmits<{
'update:column-visibility': [value: VisibilityState]
'task-status-filter-changed': [value: string]
'toggle-detail-panel': []
'toggle-column-lock': []
'toggle-task-columns': []
'bulk-create': []
'create-shot': []
@@ -323,9 +215,20 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'All Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
{ value: 'table', icon: Table2 },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -334,21 +237,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')

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