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"
|
:selected-asset="selectedAsset"
|
||||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||||
:show-thumbnails="showThumbnails"
|
:show-thumbnails="showThumbnails"
|
||||||
|
:is-columns-locked="lockColumns"
|
||||||
@update:view-mode="viewMode = $event"
|
@update:view-mode="viewMode = $event"
|
||||||
@update:category-filter="handleCategoryFilterChange"
|
@update:category-filter="handleCategoryFilterChange"
|
||||||
@update:search="searchQuery = $event"
|
@update:search="searchQuery = $event"
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
@update:show-thumbnails="handleThumbnailToggle"
|
@update:show-thumbnails="handleThumbnailToggle"
|
||||||
@task-status-filter-changed="handleTaskStatusFilter"
|
@task-status-filter-changed="handleTaskStatusFilter"
|
||||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||||
|
@toggle-column-lock="toggleColumnLock"
|
||||||
@create-asset="showCreateDialog = true"
|
@create-asset="showCreateDialog = true"
|
||||||
/>
|
/>
|
||||||
<div v-else class="flex items-center justify-center py-4">
|
<div v-else class="flex items-center justify-center py-4">
|
||||||
@@ -109,6 +111,7 @@
|
|||||||
:sorting="sorting"
|
:sorting="sorting"
|
||||||
:column-visibility="columnVisibility"
|
:column-visibility="columnVisibility"
|
||||||
:all-task-types="allTaskTypes"
|
:all-task-types="allTaskTypes"
|
||||||
|
:lock-columns="lockColumns"
|
||||||
@update:sorting="sorting = $event"
|
@update:sorting="sorting = $event"
|
||||||
@update:column-visibility="handleColumnVisibilityChange"
|
@update:column-visibility="handleColumnVisibilityChange"
|
||||||
@update:rowSelection="handleRowSelectionChange"
|
@update:rowSelection="handleRowSelectionChange"
|
||||||
@@ -183,17 +186,23 @@
|
|||||||
v-if="showPanel && selectedAsset"
|
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"
|
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
|
<AssetDetailPanel
|
||||||
|
v-else
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:asset-id="selectedAsset.id"
|
:asset-id="selectedAsset.id"
|
||||||
|
:all-task-types="allTaskTypes"
|
||||||
@close="closeDetailPanel"
|
@close="closeDetailPanel"
|
||||||
@edit="editAsset"
|
@edit="editAsset"
|
||||||
@delete="deleteAsset"
|
@delete="deleteAsset"
|
||||||
@create-task="handleCreateTask"
|
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
@create-note="handleCreateNote"
|
|
||||||
@upload-reference="handleUploadReference"
|
|
||||||
@publish-version="handlePublishVersion"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -201,18 +210,23 @@
|
|||||||
<!-- Asset Detail Panel (Mobile) -->
|
<!-- Asset Detail Panel (Mobile) -->
|
||||||
<Sheet v-model:open="showMobileDetail">
|
<Sheet v-model:open="showMobileDetail">
|
||||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
<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
|
<AssetDetailPanel
|
||||||
v-if="selectedAsset"
|
v-else-if="selectedAsset"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:asset-id="selectedAsset.id"
|
:asset-id="selectedAsset.id"
|
||||||
|
:all-task-types="allTaskTypes"
|
||||||
@close="closeDetailPanel"
|
@close="closeDetailPanel"
|
||||||
@edit="editAsset"
|
@edit="editAsset"
|
||||||
@delete="deleteAsset"
|
@delete="deleteAsset"
|
||||||
@create-task="handleCreateTask"
|
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
@create-note="handleCreateNote"
|
|
||||||
@upload-reference="handleUploadReference"
|
|
||||||
@publish-version="handlePublishVersion"
|
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -244,6 +258,7 @@ import AssetCard from "./AssetCard.vue";
|
|||||||
import AssetForm from "./AssetForm.vue";
|
import AssetForm from "./AssetForm.vue";
|
||||||
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
||||||
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
|
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
|
||||||
|
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
|
||||||
import AssetsDataTable from "./AssetsDataTable.vue";
|
import AssetsDataTable from "./AssetsDataTable.vue";
|
||||||
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
||||||
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
||||||
@@ -376,6 +391,13 @@ const showThumbnails = ref(
|
|||||||
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
|
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
|
// Computed properties
|
||||||
const assets = computed(() => assetsStore.assets);
|
const assets = computed(() => assetsStore.assets);
|
||||||
const isLoading = computed(() => assetsStore.isLoading);
|
const isLoading = computed(() => assetsStore.isLoading);
|
||||||
@@ -435,6 +457,7 @@ const assetColumns = computed(() => {
|
|||||||
onDelete: deleteAsset,
|
onDelete: deleteAsset,
|
||||||
onViewTasks: viewAssetTasks,
|
onViewTasks: viewAssetTasks,
|
||||||
onTaskStatusUpdated: handleTaskStatusUpdate,
|
onTaskStatusUpdated: handleTaskStatusUpdate,
|
||||||
|
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
||||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||||
getSelectedCount: () => selectedCount.value,
|
getSelectedCount: () => selectedCount.value,
|
||||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
||||||
@@ -564,8 +587,8 @@ const deleteAsset = async (asset: Asset) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const viewAssetTasks = (asset: Asset) => {
|
const viewAssetTasks = (asset: Asset) => {
|
||||||
// TODO: Navigate to asset tasks view
|
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
|
||||||
console.log("View tasks for asset:", asset.name);
|
selectAsset(asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
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 = () => {
|
const clearFilters = () => {
|
||||||
selectedCategory.value = "all";
|
selectedCategory.value = "all";
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
@@ -678,30 +719,19 @@ const formatTaskType = (taskType: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Detail panel event handlers
|
// Detail panel event handlers
|
||||||
const handleCreateTask = () => {
|
const selectedTaskId = ref<number | null>(null);
|
||||||
// TODO: Navigate to task creation for this asset
|
const selectedTaskTab = ref<string>('infos');
|
||||||
console.log('Create task for asset:', selectedAsset.value?.name);
|
|
||||||
|
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||||
|
selectedTaskId.value = task.id;
|
||||||
|
selectedTaskTab.value = tab || 'infos';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectTask = (task: any) => {
|
// Reset the task sub-panel whenever the asset selection changes (including close)
|
||||||
// TODO: Open task detail panel
|
watch(selectedAsset, () => {
|
||||||
console.log('Select task:', task);
|
selectedTaskId.value = null;
|
||||||
};
|
selectedTaskTab.value = 'infos';
|
||||||
|
});
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load custom task types from project
|
// Load custom task types from project
|
||||||
const loadCustomTaskTypes = async () => {
|
const loadCustomTaskTypes = async () => {
|
||||||
|
|||||||
@@ -117,7 +117,110 @@
|
|||||||
|
|
||||||
<!-- Task Status & Assignees -->
|
<!-- Task Status & Assignees -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
<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 -->
|
<!-- Loading Tasks -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||||
@@ -183,16 +286,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
AlertCircle, RefreshCw, X
|
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import AssetNotes from './AssetNotes.vue'
|
import AssetNotes from './AssetNotes.vue'
|
||||||
import AssetReferences from './AssetReferences.vue'
|
import AssetReferences from './AssetReferences.vue'
|
||||||
|
|
||||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||||
|
import { taskService } from '@/services/task'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@@ -208,16 +313,13 @@ interface Task {
|
|||||||
interface Props {
|
interface Props {
|
||||||
projectId: number
|
projectId: number
|
||||||
assetId: number
|
assetId: number
|
||||||
|
allTaskTypes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'edit', asset: Asset): void
|
(e: 'edit', asset: Asset): void
|
||||||
(e: 'delete', asset: Asset): void
|
(e: 'delete', asset: Asset): void
|
||||||
(e: 'create-task'): void
|
(e: 'select-task', task: Task, tab?: string): void
|
||||||
(e: 'select-task', task: Task): void
|
|
||||||
(e: 'create-note'): void
|
|
||||||
(e: 'upload-reference'): void
|
|
||||||
(e: 'publish-version'): void
|
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +335,7 @@ const notes = ref<any[]>([])
|
|||||||
const references = ref<any[]>([])
|
const references = ref<any[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const isCreatingTask = ref(false)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
@@ -266,6 +369,11 @@ const progressPercentage = computed(() => {
|
|||||||
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
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 taskStatusCounts = computed(() => {
|
||||||
const counts = {
|
const counts = {
|
||||||
not_started: 0,
|
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) => {
|
const formatCategory = (category: string) => {
|
||||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,19 @@
|
|||||||
<PanelRightOpen v-else class="h-4 w-4" />
|
<PanelRightOpen v-else class="h-4 w-4" />
|
||||||
</Button>
|
</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 -->
|
<!-- Clear Filters -->
|
||||||
<Button
|
<Button
|
||||||
v-if="hasFilters"
|
v-if="hasFilters"
|
||||||
@@ -250,7 +263,8 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
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'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -290,6 +304,7 @@ interface Props {
|
|||||||
selectedAsset: Asset | null
|
selectedAsset: Asset | null
|
||||||
isDetailPanelEnabled: boolean
|
isDetailPanelEnabled: boolean
|
||||||
showThumbnails: boolean
|
showThumbnails: boolean
|
||||||
|
isColumnsLocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -302,6 +317,7 @@ const emit = defineEmits<{
|
|||||||
'update:show-thumbnails': [value: boolean]
|
'update:show-thumbnails': [value: boolean]
|
||||||
'task-status-filter-changed': [value: string]
|
'task-status-filter-changed': [value: string]
|
||||||
'toggle-detail-panel': []
|
'toggle-detail-panel': []
|
||||||
|
'toggle-column-lock': []
|
||||||
'create-asset': []
|
'create-asset': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 px-4">
|
<div class="space-y-4 px-4">
|
||||||
<div class="rounded-md border">
|
<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>
|
<TableHeader>
|
||||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
<TableHead
|
<TableHead
|
||||||
@@ -67,13 +165,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
FlexRender,
|
FlexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useVueTable,
|
useVueTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
type HeaderGroup,
|
||||||
|
type Row,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
} from '@tanstack/vue-table'
|
} from '@tanstack/vue-table'
|
||||||
@@ -93,10 +193,38 @@ interface Props {
|
|||||||
sorting: SortingState
|
sorting: SortingState
|
||||||
columnVisibility: VisibilityState
|
columnVisibility: VisibilityState
|
||||||
allTaskTypes: string[]
|
allTaskTypes: string[]
|
||||||
|
lockColumns?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
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<{
|
const emit = defineEmits<{
|
||||||
'update:sorting': [sorting: SortingState]
|
'update:sorting': [sorting: SortingState]
|
||||||
'update:columnVisibility': [visibility: VisibilityState]
|
'update:columnVisibility': [visibility: VisibilityState]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative"
|
<div class="relative flex items-center gap-1">
|
||||||
>
|
|
||||||
<Select
|
<Select
|
||||||
:model-value="currentStatusId"
|
:model-value="currentStatusId"
|
||||||
@update:model-value="handleStatusChange"
|
@update:model-value="handleStatusChange"
|
||||||
@@ -33,6 +32,112 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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 -->
|
<!-- Loading indicator -->
|
||||||
<div
|
<div
|
||||||
v-if="isUpdating || isLoadingStatuses"
|
v-if="isUpdating || isLoadingStatuses"
|
||||||
@@ -52,11 +157,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { User, Search, Check, X } from 'lucide-vue-next'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/asset'
|
import { TaskStatus } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
interface StatusOption {
|
interface StatusOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -71,19 +187,47 @@ interface Props {
|
|||||||
status: TaskStatus | string
|
status: TaskStatus | string
|
||||||
taskId?: number | null
|
taskId?: number | null
|
||||||
projectId: number
|
projectId: number
|
||||||
|
assignedUserId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
|
(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 props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
// Use the shared task statuses store instead of direct API calls
|
// Use the shared task statuses store instead of direct API calls
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
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
|
// Get loading state from store
|
||||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||||
@@ -139,6 +283,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
|
// Fetch custom statuses for the project using store
|
||||||
const fetchStatuses = async () => {
|
const fetchStatuses = async () => {
|
||||||
if (!props.projectId) return
|
if (!props.projectId) return
|
||||||
@@ -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) => {
|
const handleStatusChange = async (newStatusId: any) => {
|
||||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||||
|
|
||||||
@@ -184,10 +396,14 @@ const handleStatusChange = async (newStatusId: any) => {
|
|||||||
// Fetch statuses on mount
|
// Fetch statuses on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStatuses()
|
fetchStatuses()
|
||||||
|
// Preload project members to ensure they're available when needed
|
||||||
|
loadProjectMembers()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refetch statuses when projectId changes
|
// Refetch statuses when projectId changes
|
||||||
watch(() => props.projectId, () => {
|
watch(() => props.projectId, () => {
|
||||||
fetchStatuses()
|
fetchStatuses()
|
||||||
|
// Clear project members when project changes
|
||||||
|
projectMembers.value = []
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
|
|||||||
onDelete: (asset: Asset) => void
|
onDelete: (asset: Asset) => void
|
||||||
onViewTasks: (asset: Asset) => void
|
onViewTasks: (asset: Asset) => void
|
||||||
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
||||||
|
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
|
||||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||||
getSelectedCount?: () => number
|
getSelectedCount?: () => number
|
||||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||||
@@ -225,7 +226,9 @@ export const createAssetColumns = (
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const asset = row.original
|
const asset = row.original
|
||||||
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
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, {
|
return h(EditableTaskStatus, {
|
||||||
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
||||||
@@ -234,9 +237,13 @@ export const createAssetColumns = (
|
|||||||
status,
|
status,
|
||||||
taskId,
|
taskId,
|
||||||
projectId: meta.projectId,
|
projectId: meta.projectId,
|
||||||
|
assignedUserId,
|
||||||
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
|
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
|
||||||
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
|
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
|
||||||
},
|
},
|
||||||
|
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
|
||||||
|
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
|
|||||||
@@ -41,14 +41,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||||
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}`"
|
|
||||||
/>
|
|
||||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
@@ -65,21 +58,21 @@
|
|||||||
<!-- Department Role -->
|
<!-- Department Role -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label class="text-sm">Department:</Label>
|
<Label class="text-sm">Department:</Label>
|
||||||
<select
|
<Select
|
||||||
:value="member.department_role || 'none'"
|
:model-value="member.department_role || 'none'"
|
||||||
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
|
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
|
||||||
:disabled="isUpdatingMember === member.id"
|
: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>
|
<SelectTrigger class="w-32 h-8">
|
||||||
<option value="layout">Layout</option>
|
<SelectValue />
|
||||||
<option value="animation">Animation</option>
|
</SelectTrigger>
|
||||||
<option value="lighting">Lighting</option>
|
<SelectContent>
|
||||||
<option value="composite">Composite</option>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<option value="modeling">Modeling</option>
|
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||||
<option value="rigging">Rigging</option>
|
{{ role.label }}
|
||||||
<option value="surfacing">Surfacing</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Joined Date -->
|
<!-- Joined Date -->
|
||||||
@@ -89,7 +82,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||||
<MoreHorizontal class="h-4 w-4" />
|
<MoreHorizontal class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,119 +111,84 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Member Modal -->
|
<!-- Add Member Dialog -->
|
||||||
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
<Dialog v-model:open="showAddMemberDialog">
|
||||||
<!-- Backdrop -->
|
<DialogContent class="sm:max-w-md">
|
||||||
<div
|
<DialogHeader>
|
||||||
class="fixed inset-0 bg-black/50"
|
<DialogTitle>Add Team Member</DialogTitle>
|
||||||
@click="closeAddDialog"
|
<DialogDescription>
|
||||||
></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 a user to this project and assign their department role.
|
Add a user to this project and assign their department role.
|
||||||
</p>
|
</DialogDescription>
|
||||||
</div>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- User Selection -->
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="user">User</Label>
|
<Label>User</Label>
|
||||||
<select
|
<Select v-model="newMember.userId" :disabled="isAddingMember">
|
||||||
v-model="newMember.userId"
|
<SelectTrigger>
|
||||||
:disabled="isAddingMember"
|
<SelectValue placeholder="Select a user" />
|
||||||
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"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">Select a user</option>
|
<SelectItem
|
||||||
<option
|
v-for="user in availableUsers"
|
||||||
v-for="user in (availableUsers || [])"
|
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
:value="user.id.toString()"
|
:value="user.id.toString()"
|
||||||
>
|
>
|
||||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Department Role -->
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="department">Department Role (Optional)</Label>
|
<Label>Department Role (Optional)</Label>
|
||||||
<select
|
<Select v-model="newMember.departmentRole" :disabled="isAddingMember">
|
||||||
v-model="newMember.departmentRole"
|
<SelectTrigger>
|
||||||
:disabled="isAddingMember"
|
<SelectValue placeholder="None" />
|
||||||
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"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">None</option>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<option value="layout">Layout</option>
|
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||||
<option value="animation">Animation</option>
|
{{ role.label }}
|
||||||
<option value="lighting">Lighting</option>
|
</SelectItem>
|
||||||
<option value="composite">Composite</option>
|
</SelectContent>
|
||||||
<option value="modeling">Modeling</option>
|
</Select>
|
||||||
<option value="rigging">Rigging</option>
|
|
||||||
<option value="surfacing">Surfacing</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
|
||||||
variant="outline"
|
|
||||||
@click="closeAddDialog"
|
|
||||||
:disabled="isAddingMember"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
|
||||||
@click="addMember"
|
|
||||||
:disabled="!newMember.userId || isAddingMember"
|
|
||||||
>
|
|
||||||
<div v-if="isAddingMember" class="flex items-center gap-2">
|
<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>
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
<span>Adding...</span>
|
<span>Adding...</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>Add Member</span>
|
<span v-else>Add Member</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
|
|
||||||
<!-- Remove Member Confirmation -->
|
<!-- Remove Member Confirmation -->
|
||||||
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
<AlertDialog v-model:open="showRemoveDialog">
|
||||||
<!-- Backdrop -->
|
<AlertDialogContent>
|
||||||
<div
|
<AlertDialogHeader>
|
||||||
class="fixed inset-0 bg-black/50"
|
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||||
@click="showRemoveDialog = false"
|
<AlertDialogDescription>
|
||||||
></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">
|
|
||||||
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
|
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</p>
|
</AlertDialogDescription>
|
||||||
</div>
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
<div class="flex justify-end gap-3">
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
variant="outline"
|
|
||||||
@click="showRemoveDialog = false"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click="confirmRemoveMember"
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
Remove Member
|
Remove Member
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -242,14 +200,39 @@ import {
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { userService } from '@/services/user'
|
import { userService } from '@/services/user'
|
||||||
import type { User } from '@/types/auth'
|
import type { User } from '@/types/auth'
|
||||||
@@ -259,10 +242,26 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<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
|
// State
|
||||||
const members = ref<ProjectMember[]>([])
|
const members = ref<ProjectMember[]>([])
|
||||||
const availableUsers = ref<User[]>([])
|
const allUsers = ref<User[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isUpdatingMember = ref<number | null>(null)
|
const isUpdatingMember = ref<number | null>(null)
|
||||||
@@ -273,7 +272,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
|
|||||||
|
|
||||||
const newMember = ref({
|
const newMember = ref({
|
||||||
userId: '',
|
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
|
// Methods
|
||||||
@@ -284,40 +289,50 @@ const loadMembers = async () => {
|
|||||||
members.value = await projectService.getProjectMembers(props.projectId)
|
members.value = await projectService.getProjectMembers(props.projectId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load members'
|
error.value = err instanceof Error ? err.message : 'Failed to load members'
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load project members',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAvailableUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const allUsers = await userService.getAllUsers()
|
allUsers.value = await userService.getUsers()
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load users:', err)
|
toast({
|
||||||
error.value = 'Failed to load available users'
|
title: 'Error',
|
||||||
availableUsers.value = [] // Ensure it's always an array
|
description: 'Failed to load users',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
|
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
|
||||||
try {
|
try {
|
||||||
isUpdatingMember.value = memberId
|
isUpdatingMember.value = member.id
|
||||||
await projectService.updateProjectMember(props.projectId, memberId, {
|
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
|
||||||
department_role: departmentRole as any
|
department_role: departmentRole as any
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update local state
|
const index = members.value.findIndex(m => m.id === member.id)
|
||||||
const member = members.value.find(m => m.id === memberId)
|
if (index !== -1) {
|
||||||
if (member) {
|
members.value[index] = updatedMember
|
||||||
member.department_role = departmentRole as any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Role updated',
|
||||||
|
description: 'Member department role has been updated',
|
||||||
|
})
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
isUpdatingMember.value = null
|
isUpdatingMember.value = null
|
||||||
}
|
}
|
||||||
@@ -330,7 +345,7 @@ const addMember = async () => {
|
|||||||
isAddingMember.value = true
|
isAddingMember.value = true
|
||||||
const memberData = {
|
const memberData = {
|
||||||
user_id: parseInt(newMember.value.userId),
|
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)
|
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
|
||||||
@@ -338,10 +353,16 @@ const addMember = async () => {
|
|||||||
|
|
||||||
closeAddDialog()
|
closeAddDialog()
|
||||||
|
|
||||||
// Refresh available users
|
toast({
|
||||||
await loadAvailableUsers()
|
title: 'Member added',
|
||||||
|
description: 'Team member has been added to the project',
|
||||||
|
})
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
isAddingMember.value = false
|
isAddingMember.value = false
|
||||||
}
|
}
|
||||||
@@ -358,19 +379,24 @@ const confirmRemoveMember = async () => {
|
|||||||
try {
|
try {
|
||||||
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
|
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
|
||||||
|
|
||||||
// Remove from local state
|
|
||||||
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
members.value.splice(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
|
showRemoveDialog.value = false
|
||||||
memberToRemove.value = null
|
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()
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -391,23 +413,15 @@ const formatDate = (dateString: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddDialog = async () => {
|
const openAddDialog = async () => {
|
||||||
try {
|
await loadUsers()
|
||||||
await loadAvailableUsers()
|
|
||||||
showAddMemberDialog.value = true
|
showAddMemberDialog.value = true
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to open add dialog:', err)
|
|
||||||
error.value = 'Failed to load user list'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeAddDialog = () => {
|
const closeAddDialog = () => {
|
||||||
showAddMemberDialog.value = false
|
showAddMemberDialog.value = false
|
||||||
// Reset form
|
newMember.value = { userId: '', departmentRole: 'none' }
|
||||||
newMember.value = { userId: '', departmentRole: '' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMembers()
|
loadMembers()
|
||||||
|
|||||||
@@ -173,13 +173,22 @@
|
|||||||
v-if="showPanel && selectedShot"
|
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"
|
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
|
<ShotDetailPanel
|
||||||
|
v-else
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:shot-id="selectedShot.id"
|
:shot-id="selectedShot.id"
|
||||||
:initial-shot="selectedShot"
|
:initial-shot="selectedShot"
|
||||||
|
:all-task-types="allTaskTypes"
|
||||||
@edit="editShot"
|
@edit="editShot"
|
||||||
@delete="deleteShot"
|
@delete="deleteShot"
|
||||||
@create-task="handleCreateTask"
|
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
@close="closeDetailPanel"
|
@close="closeDetailPanel"
|
||||||
/>
|
/>
|
||||||
@@ -190,14 +199,22 @@
|
|||||||
<!-- Mobile Detail Sheet -->
|
<!-- Mobile Detail Sheet -->
|
||||||
<Sheet v-model:open="showMobileDetail">
|
<Sheet v-model:open="showMobileDetail">
|
||||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
<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
|
<ShotDetailPanel
|
||||||
v-if="selectedShot"
|
v-else-if="selectedShot"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:shot-id="selectedShot.id"
|
:shot-id="selectedShot.id"
|
||||||
:initial-shot="selectedShot"
|
:initial-shot="selectedShot"
|
||||||
|
:all-task-types="allTaskTypes"
|
||||||
@edit="editShot"
|
@edit="editShot"
|
||||||
@delete="deleteShot"
|
@delete="deleteShot"
|
||||||
@create-task="handleCreateTask"
|
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -316,6 +333,7 @@ import ShotCard from './ShotCard.vue'
|
|||||||
import ShotForm from './ShotForm.vue'
|
import ShotForm from './ShotForm.vue'
|
||||||
import BulkShotForm from './BulkShotForm.vue'
|
import BulkShotForm from './BulkShotForm.vue'
|
||||||
import ShotDetailPanel from './ShotDetailPanel.vue'
|
import ShotDetailPanel from './ShotDetailPanel.vue'
|
||||||
|
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
||||||
import ShotsDataTable from './ShotsDataTable.vue'
|
import ShotsDataTable from './ShotsDataTable.vue'
|
||||||
import ShotTableToolbar from './ShotTableToolbar.vue'
|
import ShotTableToolbar from './ShotTableToolbar.vue'
|
||||||
import { createShotColumns, type ShotColumnMeta } from './columns'
|
import { createShotColumns, type ShotColumnMeta } from './columns'
|
||||||
@@ -845,15 +863,19 @@ const clearSearch = () => {
|
|||||||
loadShots()
|
loadShots()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateTask = () => {
|
const selectedTaskId = ref<number | null>(null)
|
||||||
// TODO: Navigate to task creation for this shot
|
const selectedTaskTab = ref<string>('infos')
|
||||||
console.log('Create task for shot:', selectedShot.value?.name)
|
|
||||||
|
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||||
|
selectedTaskId.value = task.id
|
||||||
|
selectedTaskTab.value = tab || 'infos'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectTask = (task: any) => {
|
// Reset the task sub-panel whenever the shot selection changes (including close)
|
||||||
// TODO: Navigate to task detail view
|
watch(selectedShot, () => {
|
||||||
console.log('View task:', task.name)
|
selectedTaskId.value = null
|
||||||
}
|
selectedTaskTab.value = 'infos'
|
||||||
|
})
|
||||||
|
|
||||||
const formatStatus = (status: ShotStatus) => {
|
const formatStatus = (status: ShotStatus) => {
|
||||||
return status.split('_').map(word =>
|
return status.split('_').map(word =>
|
||||||
|
|||||||
@@ -131,15 +131,58 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||||
<Button
|
<div class="flex items-center gap-1">
|
||||||
v-if="canCreateTask"
|
<Popover v-if="canCreateTask">
|
||||||
size="sm"
|
<PopoverTrigger as-child>
|
||||||
variant="outline"
|
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
|
||||||
@click="$emit('create-task')"
|
|
||||||
>
|
|
||||||
<Plus class="h-3 w-3 mr-1" />
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
Add Task
|
Add Task
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<!-- No Tasks -->
|
<!-- No Tasks -->
|
||||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||||
@@ -158,7 +201,7 @@
|
|||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
|
class="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 font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
@@ -178,15 +221,29 @@
|
|||||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
<h3 class="text-sm font-semibold">Production Notes</h3>
|
||||||
<Button
|
<Popover v-if="canCreateNote">
|
||||||
v-if="canCreateNote"
|
<PopoverTrigger as-child>
|
||||||
size="sm"
|
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||||
variant="outline"
|
|
||||||
@click="$emit('create-note')"
|
|
||||||
>
|
|
||||||
<Plus class="h-3 w-3 mr-1" />
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
Add Note
|
Add Note
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
@@ -222,15 +279,29 @@
|
|||||||
<TabsContent value="references" class="flex-1 p-6">
|
<TabsContent value="references" class="flex-1 p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||||
<Button
|
<Popover v-if="canUploadReferences">
|
||||||
v-if="canUploadReferences"
|
<PopoverTrigger as-child>
|
||||||
size="sm"
|
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||||
variant="outline"
|
|
||||||
@click="$emit('upload-reference')"
|
|
||||||
>
|
|
||||||
<Plus class="h-3 w-3 mr-1" />
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
Upload Reference
|
Upload Reference
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
@@ -280,14 +351,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||||
|
import { taskService } from '@/services/task'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||||
@@ -302,16 +375,14 @@ interface Props {
|
|||||||
projectId: number
|
projectId: number
|
||||||
shotId: number
|
shotId: number
|
||||||
initialShot?: Shot
|
initialShot?: Shot
|
||||||
|
allTaskTypes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'edit', shot: Shot): void
|
(e: 'edit', shot: Shot): void
|
||||||
(e: 'delete', shot: Shot): void
|
(e: 'delete', shot: Shot): void
|
||||||
(e: 'create-task'): void
|
(e: 'select-task', task: Task, tab?: string): void
|
||||||
(e: 'select-task', task: Task): void
|
|
||||||
(e: 'create-note'): void
|
|
||||||
(e: 'link-asset'): void
|
(e: 'link-asset'): void
|
||||||
(e: 'upload-reference'): void
|
|
||||||
(e: 'edit-design'): void
|
(e: 'edit-design'): void
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
@@ -326,6 +397,7 @@ const shot = ref<Shot | null>(null)
|
|||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const isCreatingTask = ref(false)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const frameCount = computed(() => {
|
const frameCount = computed(() => {
|
||||||
@@ -383,6 +455,11 @@ const canEditDesign = computed(() => {
|
|||||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
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
|
// Methods
|
||||||
const loadShotDetails = async () => {
|
const loadShotDetails = async () => {
|
||||||
try {
|
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) => {
|
const formatStatus = (status: ShotStatus) => {
|
||||||
return status.split('_').map(word =>
|
return status.split('_').map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- 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) -->
|
<!-- Tabs List (Fixed) -->
|
||||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||||
@@ -315,6 +315,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
|
initialTab?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ const columns = createColumns({
|
|||||||
onBulkStatusChange: handleBulkStatusChange,
|
onBulkStatusChange: handleBulkStatusChange,
|
||||||
onStatusUpdated: handleStatusUpdated,
|
onStatusUpdated: handleStatusUpdated,
|
||||||
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
|
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
|
// TanStack Table configuration
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { h, ref } from 'vue'
|
import { h, ref } from 'vue'
|
||||||
import type { ColumnDef } from '@tanstack/vue-table'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
|
||||||
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||||
import { type Task } from '@/services/task'
|
import { type Task } from '@/services/task'
|
||||||
@@ -27,6 +33,8 @@ interface ColumnCallbacks {
|
|||||||
onBulkStatusChange?: (status: TaskStatus) => void
|
onBulkStatusChange?: (status: TaskStatus) => void
|
||||||
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
|
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
|
||||||
getSelectedCount?: () => number
|
getSelectedCount?: () => number
|
||||||
|
onViewDetails?: (task: Task) => void
|
||||||
|
onReassign?: (task: Task) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
|
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')))
|
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 = () => {
|
const manageMembers = () => {
|
||||||
// TODO: Implement member management
|
if (projectId.value) {
|
||||||
console.log('Manage members')
|
router.push(`/projects/${projectId.value}/settings`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
const getStatusVariant = (status: string) => {
|
||||||
|
|||||||
@@ -346,9 +346,9 @@
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ProjectMembersManager
|
<ProjectMemberManagement
|
||||||
v-if="selectedProject && showMembersDialog"
|
v-if="selectedProject && showMembersDialog"
|
||||||
:project="selectedProject"
|
:project-id="selectedProject.id"
|
||||||
@close="showMembersDialog = false"
|
@close="showMembersDialog = false"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -407,7 +407,7 @@ import {
|
|||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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 type { Project } from '@/stores/projects'
|
||||||
import { apiClient } from '@/services/api'
|
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 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.
|
- 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)
|
- [x] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` — ported from the shot version; wired through `asset/columns.ts` and `AssetBrowser.vue`
|
||||||
- [ ] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` (shot version has `Lock`/`Unlock` toggle)
|
- [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)
|
||||||
- [ ] Add row-actions ("…") menu to `components/task/columns.ts` (shot/asset already have one)
|
- [x] Add row-actions ("…") menu to `components/task/columns.ts` — "View Details" + "Reassign" (no Delete/Edit — no backend `deleteTask`/task-edit-form exists)
|
||||||
- [ ] 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
|
- [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)
|
||||||
- [ ] Implement shot detail-panel task stubs in `components/shot/ShotBrowser.vue:848-856`: create task, select task
|
- [x] Implement the same for `ShotBrowser.vue`/`ShotDetailPanel.vue` (also fixed the pre-existing dead "Add Note"/"Upload Reference" buttons there)
|
||||||
- [ ] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component
|
- [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
|
||||||
- [ ] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` ("Manage Members" is currently a no-op)
|
- [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
|
## Phase 3 — Unify controls
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user