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>
This commit is contained in:
@@ -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">
|
||||
@@ -109,6 +111,7 @@
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
:lock-columns="lockColumns"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@@ -179,21 +182,27 @@
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadAssets"
|
||||
/>
|
||||
<AssetDetailPanel
|
||||
v-else
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
:all-task-types="allTaskTypes"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -201,18 +210,23 @@
|
||||
<!-- Asset Detail Panel (Mobile) -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadAssets"
|
||||
/>
|
||||
<AssetDetailPanel
|
||||
v-if="selectedAsset"
|
||||
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"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -244,6 +258,7 @@ 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";
|
||||
@@ -376,6 +391,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);
|
||||
@@ -435,6 +457,7 @@ const assetColumns = computed(() => {
|
||||
onDelete: deleteAsset,
|
||||
onViewTasks: viewAssetTasks,
|
||||
onTaskStatusUpdated: handleTaskStatusUpdate,
|
||||
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||
getSelectedCount: () => selectedCount.value,
|
||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
||||
@@ -564,8 +587,8 @@ const deleteAsset = async (asset: Asset) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -661,6 +684,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 = "";
|
||||
@@ -678,30 +719,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 () => {
|
||||
|
||||
@@ -117,8 +117,111 @@
|
||||
|
||||
<!-- Task Status & Assignees -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -182,17 +285,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, X
|
||||
import {
|
||||
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import AssetNotes from './AssetNotes.vue'
|
||||
import AssetReferences from './AssetReferences.vue'
|
||||
|
||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@@ -208,16 +313,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
|
||||
}
|
||||
|
||||
@@ -233,6 +335,7 @@ const notes = ref<any[]>([])
|
||||
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 +369,11 @@ const progressPercentage = computed(() => {
|
||||
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
||||
})
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
||||
})
|
||||
|
||||
const taskStatusCounts = computed(() => {
|
||||
const counts = {
|
||||
not_started: 0,
|
||||
@@ -365,6 +473,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)
|
||||
}
|
||||
|
||||
@@ -199,6 +199,19 @@
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Lock First Columns Toggle Button (only for list view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'list'"
|
||||
@click="$emit('toggle-column-lock')"
|
||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
|
||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||
>
|
||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||
<Unlock v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
@@ -248,9 +261,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
import {
|
||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
|
||||
Lock, Unlock
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -290,6 +304,7 @@ interface Props {
|
||||
selectedAsset: Asset | null
|
||||
isDetailPanelEnabled: boolean
|
||||
showThumbnails: boolean
|
||||
isColumnsLocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -302,6 +317,7 @@ const emit = defineEmits<{
|
||||
'update:show-thumbnails': [value: boolean]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'toggle-column-lock': []
|
||||
'create-asset': []
|
||||
}>()
|
||||
|
||||
|
||||
@@ -1,7 +1,105 @@
|
||||
<template>
|
||||
<div class="space-y-4 px-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||
<template v-if="lockColumns">
|
||||
<div v-if="hasRows" class="flex">
|
||||
<!-- Left pane: frozen columns, no horizontal scroll -->
|
||||
<div class="flex-shrink-0 border-r">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<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"
|
||||
: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 -->
|
||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<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"
|
||||
: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>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
@@ -31,7 +129,7 @@
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
:class="{
|
||||
'bg-muted/30': row.getIsSelected(),
|
||||
'table-row-selectable': true,
|
||||
'selecting': isRangeSelecting
|
||||
@@ -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,38 @@ 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)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
>
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
<div class="relative flex items-center gap-1">
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
:disabled="isUpdating || isLoadingStatuses"
|
||||
|
||||
|
||||
>
|
||||
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
|
||||
:style="{ backgroundColor: currentStatusObject.color }"
|
||||
@@ -15,14 +14,14 @@
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
:value="statusOption.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Color indicator -->
|
||||
<div
|
||||
<div
|
||||
v-if="statusOption.color"
|
||||
class="w-3 h-3 rounded-full border border-border"
|
||||
:style="{ backgroundColor: statusOption.color }"
|
||||
@@ -32,10 +31,116 @@
|
||||
</SelectItem>
|
||||
</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"
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
@@ -52,11 +157,22 @@ 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 { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -71,19 +187,47 @@ 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 isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
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))
|
||||
@@ -139,10 +283,22 @@ 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
|
||||
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
@@ -150,6 +306,62 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(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 = () => {
|
||||
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
|
||||
|
||||
@@ -184,10 +396,14 @@ 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
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
})
|
||||
</script>
|
||||
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
|
||||
onDelete: (asset: Asset) => void
|
||||
onViewTasks: (asset: Asset) => void
|
||||
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
||||
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
|
||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||
@@ -225,8 +226,10 @@ 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
|
||||
assetId: asset.id,
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +82,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -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 || [])"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
<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 }})
|
||||
</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,14 +200,39 @@ 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 type { User } from '@/types/auth'
|
||||
@@ -259,10 +242,26 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
const departmentRoles = [
|
||||
{ value: 'layout', label: 'Layout' },
|
||||
{ value: 'animation', label: 'Animation' },
|
||||
{ value: 'lighting', label: 'Lighting' },
|
||||
{ value: 'composite', label: 'Composite' },
|
||||
{ value: 'modeling', label: 'Modeling' },
|
||||
{ value: 'rigging', label: 'Rigging' },
|
||||
{ value: 'surfacing', label: 'Surfacing' },
|
||||
]
|
||||
|
||||
// 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 +272,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 +289,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,18 +345,24 @@ 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)
|
||||
members.value.push(addedMember)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -357,20 +378,25 @@ 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 +404,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 +413,17 @@ const formatDate = (dateString: string) => {
|
||||
}
|
||||
|
||||
const openAddDialog = async () => {
|
||||
try {
|
||||
await loadAvailableUsers()
|
||||
showAddMemberDialog.value = true
|
||||
} catch (err) {
|
||||
console.error('Failed to open add dialog:', err)
|
||||
error.value = 'Failed to load user list'
|
||||
}
|
||||
await loadUsers()
|
||||
showAddMemberDialog.value = true
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddMemberDialog.value = false
|
||||
// Reset form
|
||||
newMember.value = { userId: '', departmentRole: '' }
|
||||
newMember.value = { userId: '', departmentRole: 'none' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -169,17 +169,26 @@
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedShot"
|
||||
<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"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadShots"
|
||||
/>
|
||||
<ShotDetailPanel
|
||||
v-else
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
:initial-shot="selectedShot"
|
||||
:all-task-types="allTaskTypes"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@close="closeDetailPanel"
|
||||
/>
|
||||
@@ -190,14 +199,22 @@
|
||||
<!-- Mobile Detail Sheet -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadShots"
|
||||
/>
|
||||
<ShotDetailPanel
|
||||
v-if="selectedShot"
|
||||
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"
|
||||
/>
|
||||
</SheetContent>
|
||||
@@ -316,6 +333,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'
|
||||
@@ -845,15 +863,19 @@ 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 handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||
selectedTaskId.value = task.id
|
||||
selectedTaskTab.value = tab || 'infos'
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
|
||||
@@ -131,15 +131,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')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
<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="sm" class="h-8 w-8 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')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Tasks -->
|
||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||
@@ -158,7 +201,7 @@
|
||||
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)"
|
||||
@click="$emit('select-task', task, 'infos')"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
@@ -178,15 +221,29 @@
|
||||
<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>
|
||||
<Popover v-if="canCreateNote">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'notes')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -222,15 +279,29 @@
|
||||
<TabsContent value="references" class="flex-1 p-6">
|
||||
<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')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
<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">
|
||||
@@ -279,15 +350,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
@@ -302,16 +375,14 @@ 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): void
|
||||
(e: 'link-asset'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'edit-design'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
@@ -326,6 +397,7 @@ const shot = ref<Shot | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
@@ -383,6 +455,11 @@ const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadShotDetails = async () => {
|
||||
try {
|
||||
@@ -415,6 +492,20 @@ const loadTasks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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 formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tabs List (Fixed) -->
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
@@ -315,6 +315,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
initialTab?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -134,6 +134,11 @@ const columns = createColumns({
|
||||
onBulkStatusChange: handleBulkStatusChange,
|
||||
onStatusUpdated: handleStatusUpdated,
|
||||
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
|
||||
// Both actions open the task detail panel (which already has a working Reassign flow).
|
||||
// row-click is a no-op in TaskBrowser.vue (single click is reserved for selection) - only
|
||||
// row-double-click actually opens the panel, so reuse that instead.
|
||||
onViewDetails: (task: Task) => emit('row-double-click', task),
|
||||
onReassign: (task: Task) => emit('row-double-click', task),
|
||||
})
|
||||
|
||||
// TanStack Table configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { h, ref } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { ArrowUpDown, Film, Package, ChevronDown } from 'lucide-vue-next'
|
||||
import { ArrowUpDown, Film, Package, ChevronDown, MoreHorizontal, Eye, UserCog } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
|
||||
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||
import { type Task } from '@/services/task'
|
||||
@@ -27,6 +33,8 @@ interface ColumnCallbacks {
|
||||
onBulkStatusChange?: (status: TaskStatus) => void
|
||||
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
onViewDetails?: (task: Task) => void
|
||||
onReassign?: (task: Task) => void
|
||||
}
|
||||
|
||||
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
|
||||
@@ -308,5 +316,84 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
|
||||
return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at')))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const task = row.original
|
||||
return h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onMouseDown: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
callbacks?.onViewDetails?.(task)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Eye, { class: 'h-4 w-4 mr-2' }),
|
||||
'View Details',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
callbacks?.onReassign?.(task)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(UserCog, { class: 'h-4 w-4 mr-2' }),
|
||||
'Reassign',
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -147,8 +147,9 @@ const manageTechnicalSpecs = () => {
|
||||
}
|
||||
|
||||
const manageMembers = () => {
|
||||
// TODO: Implement member management
|
||||
console.log('Manage members')
|
||||
if (projectId.value) {
|
||||
router.push(`/projects/${projectId.value}/settings`)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
|
||||
@@ -346,9 +346,9 @@
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProjectMembersManager
|
||||
<ProjectMemberManagement
|
||||
v-if="selectedProject && showMembersDialog"
|
||||
:project="selectedProject"
|
||||
:project-id="selectedProject.id"
|
||||
@close="showMembersDialog = false"
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -407,7 +407,7 @@ import {
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProjectMembersManager from '@/components/project/ProjectMembersManager.vue'
|
||||
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
|
||||
import type { Project } from '@/stores/projects'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
|
||||
+12
-8
@@ -32,15 +32,19 @@ Derived from `frontend_report.md`. Checklist form for tracking progress — chec
|
||||
- Fixed two pre-existing backend bugs that 500'd once real data was wired up: `backend/routers/reviews.py` (`joinedload("reviewer")` string → `joinedload(Review.reviewer)`) and `backend/utils/activity.py` (wrong `models.submission` import path → `models.task`).
|
||||
- Fixed a pre-existing bug shared by both `ShotDeleteConfirmDialog.vue` and `AssetDeleteConfirmDialog.vue`: their `watch(() => props.open, ...)` lacked `{ immediate: true }`, so the impact-summary section never rendered since the dialog only mounts once already open. Deletion itself worked fine either way — this was purely cosmetic, but it was the whole point of the "impact summary" safety feature.
|
||||
|
||||
## Phase 2 — Close feature gaps (asset vs. shot parity)
|
||||
## Phase 2 — Close feature gaps (asset vs. shot parity) ✅ (done)
|
||||
|
||||
- [ ] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` (shot version at `components/shot/EditableTaskStatus.vue` is the reference)
|
||||
- [ ] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` (shot version has `Lock`/`Unlock` toggle)
|
||||
- [ ] Add row-actions ("…") menu to `components/task/columns.ts` (shot/asset already have one)
|
||||
- [ ] Implement the six stubbed asset detail-panel actions in `components/asset/AssetBrowser.vue:571-574, 680-703`: create task, select task, create note, upload reference, publish version — or hide the affordances until built
|
||||
- [ ] Implement shot detail-panel task stubs in `components/shot/ShotBrowser.vue:848-856`: create task, select task
|
||||
- [ ] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component
|
||||
- [ ] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` ("Manage Members" is currently a no-op)
|
||||
- [x] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` — ported from the shot version; wired through `asset/columns.ts` and `AssetBrowser.vue`
|
||||
- [x] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` — two-pane frozen-column layout ported into `AssetsDataTable.vue` (adapted for asset's page-scroll layout, no vertical scroll-sync needed unlike shot's bounded-height container)
|
||||
- [x] Add row-actions ("…") menu to `components/task/columns.ts` — "View Details" + "Reassign" (no Delete/Edit — no backend `deleteTask`/task-edit-form exists)
|
||||
- [x] Implement the asset detail-panel actions in `AssetBrowser.vue`/`AssetDetailPanel.vue`: create task (task-type picker), select task (opens `TaskDetailPanel` in-place), create note/upload reference/publish version (task picker → deep-links into `TaskDetailPanel`'s Notes/Attachments/Submissions tabs via new `initialTab` prop)
|
||||
- [x] Implement the same for `ShotBrowser.vue`/`ShotDetailPanel.vue` (also fixed the pre-existing dead "Add Note"/"Upload Reference" buttons there)
|
||||
- [x] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component (kept `ProjectMemberManagement.vue`, deleted the other) — combined remove-confirmation + approved-user filtering + real avatars from one with toast feedback + shared `Select`/`Dialog` UI-kit from the other
|
||||
- [x] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` — navigates to the project Settings "Team" tab (mirrors the existing `manageTechnicalSpecs` pattern) rather than a duplicate dialog
|
||||
|
||||
**Bugs found and fixed during verification (approved mid-implementation):**
|
||||
- `GET /assets/{id}` never returned `task_details` (schema didn't even declare the field) — unlike `GET /shots/{id}`, which already did. Broke the asset detail panel's task list and, transitively, every new create-task/note/reference/version feature. Fixed in `backend/schemas/asset.py` + `backend/routers/assets.py`.
|
||||
- `TaskBrowser.vue`'s `handleRowClick` is a no-op by design (single click reserved for selection) — the new row-actions menu's "View Details"/"Reassign" needed to emit `row-double-click` instead, which is what actually opens the panel.
|
||||
|
||||
## Phase 3 — Unify controls
|
||||
|
||||
|
||||
Reference in New Issue
Block a user