e628c99498
Shot/Asset/Task data tables now keep their header row pinned while scrolling (both the locked two-pane and single-table modes), matching a common data- table expectation that was missing everywhere. Brings Asset and Task browsers to full structural parity with Shot's bounded- height layout (fixed toolbar, internally-scrolling table) instead of their previous page-scroll model with a sticky-positioned toolbar. AssetsDataTable gained the synced frozen/movable-pane scrolling it was missing entirely; TasksDataTable and both browsers/parent views got the same container model. The global My Tasks page (/tasks) now reuses TaskTableToolbar and TasksDataTable instead of its own ad hoc filter selects and TaskList, including the detail panel, bulk status/assignment context menu, and sticky header for free. Since it spans multiple projects, TaskTableToolbar gained an optional Project filter (only rendered when a project list is passed in, so the project-scoped Tasks page is unaffected); episode/assignee filters populate once a specific project is chosen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
799 lines
25 KiB
Vue
799 lines
25 KiB
Vue
<template>
|
|
<div class="relative h-full flex flex-col">
|
|
<!-- Main Content -->
|
|
<div class="flex flex-col flex-1 min-h-0">
|
|
<!-- Toolbar -->
|
|
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
|
<AssetTableToolbar
|
|
v-if="customTaskTypesLoaded"
|
|
:view-mode="viewMode"
|
|
:category-filter="selectedCategory"
|
|
:search="searchQuery"
|
|
:column-visibility="columnVisibility"
|
|
:categories="categories"
|
|
:all-task-types="allTaskTypes"
|
|
:project-id="projectId"
|
|
:selected-asset="selectedAsset"
|
|
:is-detail-panel-enabled="isDetailPanelEnabled"
|
|
:show-thumbnails="showThumbnails"
|
|
:is-columns-locked="lockColumns"
|
|
@update:view-mode="viewMode = $event"
|
|
@update:category-filter="handleCategoryFilterChange"
|
|
@update:search="searchQuery = $event"
|
|
@update:column-visibility="handleColumnVisibilityChange"
|
|
@update:show-thumbnails="handleThumbnailToggle"
|
|
@task-status-filter-changed="handleTaskStatusFilter"
|
|
@toggle-detail-panel="toggleDetailPanelEnabled"
|
|
@toggle-column-lock="toggleColumnLock"
|
|
@create-asset="showCreateDialog = true"
|
|
/>
|
|
<div v-else class="flex items-center justify-center py-4">
|
|
<div class="flex items-center gap-2">
|
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
<span class="text-sm text-muted-foreground">Loading toolbar...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
|
|
></div>
|
|
<span class="text-muted-foreground">Loading assets...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
|
|
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
|
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
|
|
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
|
<Button @click="loadAssets" variant="outline">
|
|
<RefreshCw class="h-4 w-4 mr-2" />
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-else-if="
|
|
filteredAssets.length === 0 && !searchQuery && !selectedCategory
|
|
"
|
|
class="text-center py-12 px-4 sm:px-6"
|
|
>
|
|
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
|
|
<p class="text-muted-foreground mb-4">
|
|
Create your first asset to get started
|
|
</p>
|
|
<Button @click="showCreateDialog = true">
|
|
<Plus class="h-4 w-4 mr-2" />
|
|
Create Asset
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- No Results State -->
|
|
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
|
|
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
|
|
<p class="text-muted-foreground mb-4">
|
|
Try adjusting your search or filter criteria
|
|
</p>
|
|
<Button @click="clearFilters" variant="outline"> Clear Filters </Button>
|
|
</div>
|
|
|
|
<!-- Assets Grid/List -->
|
|
<div v-else class="flex-1 min-h-0">
|
|
<!-- Grid View -->
|
|
<div
|
|
v-if="viewMode === 'grid'"
|
|
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
|
|
>
|
|
<AssetCard
|
|
v-for="asset in filteredAssets"
|
|
:key="asset.id"
|
|
:asset="asset"
|
|
:show-thumbnail="showThumbnails"
|
|
@select="selectAsset"
|
|
@edit="editAsset"
|
|
@delete="deleteAsset"
|
|
@view-tasks="viewAssetTasks"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Table View -->
|
|
<AssetsDataTable
|
|
v-else-if="customTaskTypesLoaded"
|
|
:columns="assetColumns"
|
|
:data="filteredAssets"
|
|
:sorting="sorting"
|
|
:column-visibility="columnVisibility"
|
|
:all-task-types="allTaskTypes"
|
|
:lock-columns="lockColumns"
|
|
@update:sorting="sorting = $event"
|
|
@update:column-visibility="handleColumnVisibilityChange"
|
|
@update:rowSelection="handleRowSelectionChange"
|
|
@row-click="handleRowClick"
|
|
/>
|
|
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
|
|
<div class="flex items-center gap-2">
|
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
<span class="text-muted-foreground">Loading table...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Asset Dialog -->
|
|
<Dialog v-model:open="showCreateDialog">
|
|
<DialogContent class="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Create New Asset</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new asset to the project. Assets can be characters, props,
|
|
sets, or vehicles.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<AssetForm
|
|
:project-id="projectId"
|
|
:is-loading="isCreating"
|
|
@submit="handleCreateAsset"
|
|
@cancel="showCreateDialog = false"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Edit Asset Dialog -->
|
|
<Dialog v-model:open="showEditDialog">
|
|
<DialogContent class="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Asset</DialogTitle>
|
|
<DialogDescription>
|
|
Update the asset information and settings.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<AssetForm
|
|
:project-id="projectId"
|
|
:asset="selectedAsset || undefined"
|
|
:is-loading="isUpdating"
|
|
@submit="handleUpdateAsset"
|
|
@cancel="showEditDialog = false"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<AssetDeleteConfirmDialog
|
|
v-if="deletionInfo && assetToDelete"
|
|
:open="showDeleteDialog"
|
|
:asset-id="assetToDelete.id"
|
|
:asset-name="deletionInfo.asset_name"
|
|
@update:open="showDeleteDialog = $event"
|
|
@confirm-delete="handleDeleteAsset"
|
|
/>
|
|
</div>
|
|
<!-- Asset Detail Panel (Desktop + Mobile) -->
|
|
<DetailPanelOverlay
|
|
:visible="!!(showPanel && selectedAsset)"
|
|
v-model:mobile-open="showMobileDetail"
|
|
>
|
|
<TaskDetailPanel
|
|
v-if="selectedTaskId"
|
|
:key="selectedTaskId"
|
|
:task-id="selectedTaskId"
|
|
:initial-tab="selectedTaskTab"
|
|
@close="selectedTaskId = null"
|
|
@task-updated="loadAssets"
|
|
/>
|
|
<AssetDetailPanel
|
|
v-else-if="selectedAsset"
|
|
:project-id="projectId"
|
|
:asset-id="selectedAsset.id"
|
|
:all-task-types="allTaskTypes"
|
|
@close="closeDetailPanel"
|
|
@edit="editAsset"
|
|
@delete="deleteAsset"
|
|
@select-task="handleSelectTask"
|
|
/>
|
|
</DetailPanelOverlay>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
import {
|
|
Search,
|
|
Plus,
|
|
Package,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
Users,
|
|
Building,
|
|
Car,
|
|
} from "lucide-vue-next";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
|
|
import AssetCard from "./AssetCard.vue";
|
|
import AssetForm from "./AssetForm.vue";
|
|
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
|
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
|
|
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
|
|
import AssetsDataTable from "./AssetsDataTable.vue";
|
|
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
|
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
|
import { useAssetsStore } from "@/stores/assets";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useTaskStatusesStore } from "@/stores/taskStatuses";
|
|
import { useDetailPanel } from "@/composables/useDetailPanel";
|
|
import {
|
|
assetService,
|
|
AssetCategory,
|
|
TaskStatus,
|
|
type Asset,
|
|
type AssetCreate,
|
|
type AssetUpdate,
|
|
type AssetDeletionInfo,
|
|
} from "@/services/asset";
|
|
import { useToast } from "@/components/ui/toast/use-toast";
|
|
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
|
|
|
|
interface Props {
|
|
projectId: number;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
// Stores and composables
|
|
const assetsStore = useAssetsStore();
|
|
const authStore = useAuthStore();
|
|
const taskStatusesStore = useTaskStatusesStore();
|
|
const { toast } = useToast();
|
|
|
|
// TanStack Table state
|
|
const sorting = ref<SortingState>([])
|
|
const columnVisibility = ref<VisibilityState>({})
|
|
const rowSelection = ref<Record<string, boolean>>({})
|
|
|
|
// Computed for selected count
|
|
const selectedCount = computed(() => {
|
|
return Object.keys(rowSelection.value).length
|
|
})
|
|
|
|
const initializeColumnVisibility = () => {
|
|
const stored = sessionStorage.getItem('assetBrowser.columnVisibility')
|
|
if (stored) {
|
|
try {
|
|
const parsedVisibility = JSON.parse(stored)
|
|
|
|
// Ensure custom task types are included in stored visibility
|
|
const updatedVisibility = { ...parsedVisibility }
|
|
let hasChanges = false
|
|
|
|
for (const customType of customTaskTypes.value) {
|
|
if (!(customType in updatedVisibility)) {
|
|
updatedVisibility[customType] = true
|
|
hasChanges = true
|
|
}
|
|
}
|
|
|
|
columnVisibility.value = updatedVisibility
|
|
|
|
if (hasChanges) {
|
|
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(updatedVisibility))
|
|
}
|
|
} catch {
|
|
// Fall back to defaults
|
|
setDefaultColumnVisibility()
|
|
}
|
|
} else {
|
|
// Default visible columns
|
|
setDefaultColumnVisibility()
|
|
}
|
|
}
|
|
|
|
const setDefaultColumnVisibility = () => {
|
|
const defaultVisibility: Record<string, boolean> = {
|
|
name: true,
|
|
category: true,
|
|
status: true,
|
|
thumbnail: false,
|
|
modeling: true,
|
|
surfacing: true,
|
|
rigging: true,
|
|
description: true,
|
|
updatedAt: true
|
|
}
|
|
|
|
// Add custom task types to default visibility if they exist
|
|
for (const customType of customTaskTypes.value) {
|
|
defaultVisibility[customType] = true
|
|
}
|
|
|
|
columnVisibility.value = defaultVisibility
|
|
}
|
|
|
|
// Don't initialize immediately - wait for custom task types to load
|
|
// initializeColumnVisibility()
|
|
|
|
// Detail panel composable
|
|
const {
|
|
isDetailPanelEnabled,
|
|
selectedEntity: selectedAsset,
|
|
showMobileDetail,
|
|
showPanel,
|
|
toggleDetailPanelEnabled,
|
|
closeDetailPanel,
|
|
selectEntity: selectAsset,
|
|
handleRowClick: handleRowClickComposable
|
|
} = useDetailPanel<Asset>({
|
|
isDialogOpen: () => showCreateDialog.value || showEditDialog.value || showDeleteDialog.value,
|
|
sessionStorageKey: 'assetBrowser.detailPanelEnabled'
|
|
})
|
|
|
|
// Reactive state - default to list view to show task status
|
|
const viewMode = ref<"grid" | "list">("list");
|
|
const selectedCategory = ref<AssetCategory | "all">("all");
|
|
const searchQuery = ref("");
|
|
const showCreateDialog = ref(false);
|
|
const showEditDialog = ref(false);
|
|
const showDeleteDialog = ref(false);
|
|
const isCreating = ref(false);
|
|
const isUpdating = ref(false);
|
|
|
|
const assetToDelete = ref<Asset | null>(null);
|
|
const deletionInfo = ref<AssetDeletionInfo | null>(null);
|
|
|
|
const taskStatusFilter = ref('')
|
|
|
|
// Thumbnail display state - with session storage
|
|
const showThumbnails = ref(
|
|
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
|
|
);
|
|
|
|
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
|
|
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
|
|
const toggleColumnLock = () => {
|
|
lockColumns.value = !lockColumns.value;
|
|
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
|
|
};
|
|
|
|
// Computed properties
|
|
const assets = computed(() => assetsStore.assets);
|
|
const isLoading = computed(() => assetsStore.isLoading);
|
|
const error = computed(() => assetsStore.error);
|
|
|
|
const categories = [
|
|
{ value: AssetCategory.CHARACTERS, label: "Characters", icon: Users },
|
|
{ value: AssetCategory.PROPS, label: "Props", icon: Package },
|
|
{ value: AssetCategory.SETS, label: "Sets", icon: Building },
|
|
{ value: AssetCategory.VEHICLES, label: "Vehicles", icon: Car },
|
|
];
|
|
|
|
const filteredAssets = computed(() => {
|
|
let filtered = [...(assets.value || [])];
|
|
|
|
// Filter out soft deleted assets unless user is admin
|
|
if (!authStore.isAdmin) {
|
|
filtered = filtered.filter(asset => !asset.deleted_at);
|
|
}
|
|
|
|
// Filter by category
|
|
if (selectedCategory.value && selectedCategory.value !== "all") {
|
|
filtered = filtered.filter(
|
|
(asset) => asset.category === selectedCategory.value
|
|
);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.value.trim()) {
|
|
const query = searchQuery.value.toLowerCase().trim();
|
|
filtered = filtered.filter(
|
|
(asset) =>
|
|
asset.name.toLowerCase().includes(query) ||
|
|
asset.description?.toLowerCase().includes(query) ||
|
|
asset.category.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
// Custom task types from project
|
|
const customTaskTypes = ref<string[]>([]);
|
|
const customTaskTypesLoaded = ref(false);
|
|
|
|
// All available task types for assets (standard + custom)
|
|
const allTaskTypes = computed(() => {
|
|
return ["modeling", "surfacing", "rigging", ...customTaskTypes.value];
|
|
});
|
|
|
|
// Asset columns for TanStack Table
|
|
const assetColumns = computed(() => {
|
|
const meta: AssetColumnMeta = {
|
|
projectId: props.projectId,
|
|
categories: categories,
|
|
onEdit: editAsset,
|
|
onDelete: deleteAsset,
|
|
onViewTasks: viewAssetTasks,
|
|
onTaskStatusUpdated: handleTaskStatusUpdate,
|
|
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
|
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
|
getSelectedCount: () => selectedCount.value,
|
|
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
|
}
|
|
|
|
return createAssetColumns(allTaskTypes.value, meta);
|
|
})
|
|
|
|
const loadAssets = async () => {
|
|
console.log("AssetBrowser - Loading assets for project:", props.projectId);
|
|
console.log(
|
|
"AssetBrowser - Current assets count before fetch:",
|
|
assetsStore.assets.length
|
|
);
|
|
try {
|
|
const options: any = {};
|
|
|
|
if (selectedCategory.value && selectedCategory.value !== 'all') {
|
|
options.category = selectedCategory.value;
|
|
}
|
|
|
|
if (taskStatusFilter.value) {
|
|
options.taskStatusFilter = taskStatusFilter.value;
|
|
}
|
|
|
|
// Convert TanStack Table sorting to API format
|
|
if (sorting.value.length > 0) {
|
|
const sort = sorting.value[0]
|
|
options.sortBy = sort.id;
|
|
options.sortDirection = sort.desc ? 'desc' : 'asc';
|
|
}
|
|
|
|
await assetsStore.fetchAssets(props.projectId, options);
|
|
console.log(
|
|
"AssetBrowser - Assets loaded successfully:",
|
|
assetsStore.assets.length
|
|
);
|
|
console.log("AssetBrowser - Assets data:", assetsStore.assets);
|
|
console.log("AssetBrowser - View mode:", viewMode.value);
|
|
console.log("AssetBrowser - Visible columns:", columnVisibility.value);
|
|
if (assetsStore.assets.length > 0) {
|
|
console.log("AssetBrowser - First asset task status:", assetsStore.assets[0].task_status);
|
|
console.log("AssetBrowser - First asset task details:", assetsStore.assets[0].task_details);
|
|
}
|
|
} catch (err) {
|
|
console.error("AssetBrowser - Failed to load assets:", err);
|
|
console.error("AssetBrowser - Error details:", err);
|
|
}
|
|
};
|
|
|
|
// TanStack Table event handlers
|
|
const handleColumnVisibilityChange = (visibility: VisibilityState) => {
|
|
columnVisibility.value = visibility
|
|
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(visibility))
|
|
}
|
|
|
|
const handleRowSelectionChange = (selection: Record<string, boolean>) => {
|
|
rowSelection.value = selection
|
|
}
|
|
|
|
const handleRowClick = (asset: Asset, event: MouseEvent) => {
|
|
// Use the detail panel composable for row click handling
|
|
handleRowClickComposable(asset, event)
|
|
}
|
|
|
|
const handleCategoryFilterChange = (category: AssetCategory | 'all') => {
|
|
selectedCategory.value = category
|
|
}
|
|
|
|
const handleThumbnailToggle = (show: boolean) => {
|
|
showThumbnails.value = show
|
|
// Sync with column visibility
|
|
columnVisibility.value = {
|
|
...columnVisibility.value,
|
|
thumbnail: show
|
|
}
|
|
}
|
|
|
|
const handleBulkTaskStatusChange = async (taskType: string, newStatus: TaskStatus) => {
|
|
const selectedAssetIds = Object.keys(rowSelection.value).map(id => parseInt(id))
|
|
|
|
if (selectedAssetIds.length === 0) {
|
|
toast({
|
|
title: "No assets selected",
|
|
description: "Please select assets to update their task status.",
|
|
variant: "destructive",
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Use the new bulk update method from the store
|
|
await assetsStore.bulkUpdateTaskStatus(selectedAssetIds, taskType, newStatus)
|
|
|
|
toast({
|
|
title: "Task status updated",
|
|
description: `Updated ${formatTaskType(taskType)} status for ${selectedAssetIds.length} asset(s).`,
|
|
})
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to update task status",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
|
|
const editAsset = (asset: Asset) => {
|
|
selectedAsset.value = asset;
|
|
showEditDialog.value = true;
|
|
};
|
|
|
|
const deleteAsset = async (asset: Asset) => {
|
|
// Don't set selectedAsset here as it opens the detail panel
|
|
assetToDelete.value = asset;
|
|
|
|
try {
|
|
deletionInfo.value = await assetService.getAssetDeletionInfo(asset.id);
|
|
showDeleteDialog.value = true;
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to get asset information",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const viewAssetTasks = (asset: Asset) => {
|
|
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
|
|
selectAsset(asset);
|
|
};
|
|
|
|
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
|
try {
|
|
isCreating.value = true;
|
|
// When creating, we know it's AssetCreate, but TypeScript needs the union type
|
|
await assetsStore.createAsset(props.projectId, assetData as AssetCreate);
|
|
showCreateDialog.value = false;
|
|
toast({
|
|
title: "Asset created",
|
|
description: `${(assetData as AssetCreate).name} has been created successfully.`,
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to create asset",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
isCreating.value = false;
|
|
}
|
|
};
|
|
|
|
const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
|
if (!selectedAsset.value) return;
|
|
|
|
try {
|
|
isUpdating.value = true;
|
|
// When updating, we know it's AssetUpdate, but TypeScript needs the union type
|
|
await assetsStore.updateAsset(selectedAsset.value.id, assetData as AssetUpdate);
|
|
showEditDialog.value = false;
|
|
selectedAsset.value = null;
|
|
toast({
|
|
title: "Asset updated",
|
|
description: "Asset has been updated successfully.",
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to update asset",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
isUpdating.value = false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteAsset = async () => {
|
|
if (!assetToDelete.value) return;
|
|
|
|
try {
|
|
await assetsStore.deleteAsset(assetToDelete.value.id);
|
|
|
|
showDeleteDialog.value = false;
|
|
assetToDelete.value = null;
|
|
|
|
const taskCount = deletionInfo.value?.task_count || 0;
|
|
deletionInfo.value = null;
|
|
toast({
|
|
title: "Asset deleted",
|
|
description: taskCount > 0
|
|
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
|
|
: "Asset has been deleted successfully.",
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to delete asset",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTaskStatusUpdate = async (
|
|
assetId: number,
|
|
taskType: string,
|
|
newStatus: string
|
|
) => {
|
|
try {
|
|
// Update the asset in the store
|
|
await assetsStore.updateTaskStatus(assetId, taskType, newStatus as TaskStatus);
|
|
|
|
toast({
|
|
title: "Task status updated",
|
|
description: `${formatTaskType(taskType)} status updated successfully.`,
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to update task status",
|
|
description: err instanceof Error ? err.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
|
|
// Update local state instead of reloading all assets
|
|
const asset = assetsStore.assets.find((a) => a.id === assetId);
|
|
if (asset && asset.task_details) {
|
|
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
|
|
if (taskDetail) {
|
|
taskDetail.assigned_user_id = userId ?? undefined;
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: "Task assignment updated",
|
|
description: userId
|
|
? `${formatTaskType(taskType)} task assigned successfully.`
|
|
: `${formatTaskType(taskType)} task unassigned successfully.`,
|
|
});
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
selectedCategory.value = "all";
|
|
searchQuery.value = "";
|
|
taskStatusFilter.value = "";
|
|
loadAssets();
|
|
};
|
|
|
|
const handleTaskStatusFilter = (filter: string) => {
|
|
taskStatusFilter.value = filter;
|
|
loadAssets();
|
|
};
|
|
|
|
const formatTaskType = (taskType: string) => {
|
|
return taskType.charAt(0).toUpperCase() + taskType.slice(1);
|
|
};
|
|
|
|
// Detail panel event handlers
|
|
const selectedTaskId = ref<number | null>(null);
|
|
const selectedTaskTab = ref<string>('infos');
|
|
|
|
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
|
selectedTaskId.value = task.id;
|
|
selectedTaskTab.value = tab || 'infos';
|
|
};
|
|
|
|
// Reset the task sub-panel whenever the asset selection changes (including close)
|
|
watch(selectedAsset, () => {
|
|
selectedTaskId.value = null;
|
|
selectedTaskTab.value = 'infos';
|
|
});
|
|
|
|
// Load custom task types from project
|
|
const loadCustomTaskTypes = async () => {
|
|
if (!props.projectId) return;
|
|
|
|
try {
|
|
const { projectService } = await import('@/services/project');
|
|
const project = await projectService.getProject(props.projectId);
|
|
|
|
customTaskTypes.value = project.custom_asset_task_types || [];
|
|
customTaskTypesLoaded.value = true;
|
|
|
|
// Initialize column visibility AFTER custom task types are loaded
|
|
initializeColumnVisibility();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load custom task types:', error);
|
|
customTaskTypesLoaded.value = true; // Mark as loaded even if failed
|
|
// Initialize with defaults even if loading fails
|
|
initializeColumnVisibility();
|
|
}
|
|
};
|
|
|
|
// Watchers
|
|
watch(
|
|
() => props.projectId,
|
|
async (newProjectId) => {
|
|
if (newProjectId) {
|
|
// Reset loaded flag when project changes
|
|
customTaskTypesLoaded.value = false;
|
|
|
|
// Load custom task types first, then assets
|
|
try {
|
|
await loadCustomTaskTypes();
|
|
} catch (err) {
|
|
console.warn('Could not load custom task types for new project:', err);
|
|
}
|
|
loadAssets();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Watch for custom task types changes and reinitialize column visibility
|
|
watch(customTaskTypes, (newCustomTypes) => {
|
|
if (newCustomTypes.length > 0) {
|
|
// Reinitialize column visibility to include new custom types
|
|
initializeColumnVisibility();
|
|
}
|
|
}, { deep: true });
|
|
|
|
// Clear selections when filters change
|
|
watch([selectedCategory, searchQuery], () => {
|
|
rowSelection.value = {};
|
|
});
|
|
|
|
// Save thumbnail toggle preference to session storage
|
|
watch(showThumbnails, (newValue) => {
|
|
sessionStorage.setItem('assetBrowser.showThumbnails', newValue.toString());
|
|
// Sync with column visibility
|
|
columnVisibility.value = {
|
|
...columnVisibility.value,
|
|
thumbnail: newValue
|
|
};
|
|
});
|
|
|
|
// Sync column visibility with thumbnail toggle
|
|
watch(() => columnVisibility.value.thumbnail, (newValue) => {
|
|
showThumbnails.value = newValue;
|
|
});
|
|
|
|
// Watch for filter changes and reload assets
|
|
watch([selectedCategory, taskStatusFilter], () => {
|
|
loadAssets();
|
|
});
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
if (props.projectId) {
|
|
// Load task statuses for bulk operations
|
|
taskStatusesStore.fetchProjectStatuses(props.projectId).catch(err => {
|
|
console.warn('Could not load task statuses:', err);
|
|
});
|
|
|
|
// Load custom task types FIRST (blocking) to ensure columns are created correctly
|
|
try {
|
|
await loadCustomTaskTypes();
|
|
} catch (err) {
|
|
console.warn('Could not load custom task types:', err);
|
|
// Continue without custom task types
|
|
customTaskTypesLoaded.value = true;
|
|
}
|
|
|
|
// Then load assets
|
|
loadAssets();
|
|
}
|
|
});
|
|
</script> |