Compare commits
4 Commits
61ee1a7364
...
cd3628a255
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3628a255 | |||
| e8b26487db | |||
| cd2efe3587 | |||
| c63a71883b |
@@ -474,10 +474,19 @@ async def get_asset(
|
||||
# This avoids a separate COUNT query
|
||||
active_tasks = [task for task in asset.tasks if task.deleted_at is None]
|
||||
task_count = len(active_tasks)
|
||||
|
||||
|
||||
asset_data = AssetResponse.model_validate(asset)
|
||||
asset_data.task_count = task_count
|
||||
|
||||
asset_data.task_details = [
|
||||
TaskStatusInfo(
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
task_id=task.id,
|
||||
assigned_user_id=task.assigned_user_id
|
||||
)
|
||||
for task in active_tasks
|
||||
]
|
||||
|
||||
return asset_data
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ async def get_pending_reviews(
|
||||
query = db.query(Submission).options(
|
||||
joinedload(Submission.user),
|
||||
joinedload(Submission.task).joinedload(Task.project),
|
||||
joinedload(Submission.reviews).joinedload("reviewer")
|
||||
joinedload(Submission.reviews).joinedload(Review.reviewer)
|
||||
).join(Task).filter(
|
||||
Submission.deleted_at.is_(None),
|
||||
Task.deleted_at.is_(None)
|
||||
|
||||
+14
-13
@@ -25,19 +25,6 @@ class AssetUpdate(BaseModel):
|
||||
status: Optional[AssetStatus] = None
|
||||
|
||||
|
||||
class AssetResponse(AssetBase):
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Summary information
|
||||
task_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskStatusInfo(BaseModel):
|
||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
@@ -45,6 +32,20 @@ class TaskStatusInfo(BaseModel):
|
||||
assigned_user_id: Optional[int] = None
|
||||
|
||||
|
||||
class AssetResponse(AssetBase):
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Summary information
|
||||
task_count: int = 0
|
||||
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssetListResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -302,10 +302,9 @@ class ActivityService:
|
||||
"""Get activities excluding those related to deleted records."""
|
||||
from sqlalchemy import and_, or_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from models.task import Task
|
||||
from models.task import Task, Submission
|
||||
from models.shot import Shot
|
||||
from models.asset import Asset
|
||||
from models.submission import Submission
|
||||
|
||||
query = db.query(Activity)
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Activity Timeline</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="loadActivities"
|
||||
:disabled="loading"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
Loading timeline...
|
||||
</div>
|
||||
|
||||
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Clock class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No activity recorded</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
<!-- Timeline items -->
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="relative pl-10"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-2.5 w-3 h-3 rounded-full border-2 border-background"
|
||||
:class="getTimelineDotColor(activity.type)"
|
||||
/>
|
||||
|
||||
<!-- Activity content -->
|
||||
<div class="bg-card border rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="getActivityIcon(activity.type)"
|
||||
class="h-5 w-5 mt-0.5 flex-shrink-0"
|
||||
:class="getActivityColor(activity.type)"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{{ getActivityTitle(activity.type) }}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{{ activity.description }}</p>
|
||||
|
||||
<!-- Metadata display -->
|
||||
<div v-if="activity.activity_metadata" class="mt-2 text-xs text-muted-foreground">
|
||||
<div v-if="activity.activity_metadata.old_status && activity.activity_metadata.new_status">
|
||||
<Badge variant="outline" class="mr-2">{{ activity.activity_metadata.old_status }}</Badge>
|
||||
→
|
||||
<Badge variant="outline" class="ml-2">{{ activity.activity_metadata.new_status }}</Badge>
|
||||
</div>
|
||||
<div v-if="activity.activity_metadata.version">
|
||||
Version {{ activity.activity_metadata.version }}
|
||||
</div>
|
||||
<div v-if="activity.activity_metadata.decision">
|
||||
Decision: <Badge :variant="activity.activity_metadata.decision === 'approved' ? 'default' : 'destructive'">
|
||||
{{ activity.activity_metadata.decision }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Avatar class="h-5 w-5">
|
||||
<AvatarFallback class="text-xs">
|
||||
{{ getInitials(activity.user) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ activity.user.first_name }} {{ activity.user.last_name }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">•</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
RefreshCw
|
||||
} from 'lucide-vue-next'
|
||||
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
|
||||
import * as activityService from '@/services/activity'
|
||||
|
||||
interface Props {
|
||||
taskId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const activities = ref<Activity[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
watch(() => props.taskId, () => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
async function loadActivities() {
|
||||
loading.value = true
|
||||
try {
|
||||
activities.value = await activityService.getTaskActivities(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task activities:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(user: UserInfo): string {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase()
|
||||
}
|
||||
|
||||
function getActivityIcon(type: ActivityType) {
|
||||
const iconMap: Record<string, any> = {
|
||||
task_created: FileText,
|
||||
task_updated: FileText,
|
||||
task_assigned: UserPlus,
|
||||
task_status_changed: CheckCircle,
|
||||
submission_created: FileText,
|
||||
submission_reviewed: CheckCircle,
|
||||
comment_added: MessageSquare
|
||||
}
|
||||
return iconMap[type] || FileText
|
||||
}
|
||||
|
||||
function getActivityColor(type: ActivityType): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
task_created: 'text-blue-500',
|
||||
task_updated: 'text-blue-500',
|
||||
task_assigned: 'text-purple-500',
|
||||
task_status_changed: 'text-green-500',
|
||||
submission_created: 'text-orange-500',
|
||||
submission_reviewed: 'text-green-500',
|
||||
comment_added: 'text-gray-500'
|
||||
}
|
||||
return colorMap[type] || 'text-gray-500'
|
||||
}
|
||||
|
||||
function getTimelineDotColor(type: ActivityType): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
task_created: 'bg-blue-500',
|
||||
task_updated: 'bg-blue-500',
|
||||
task_assigned: 'bg-purple-500',
|
||||
task_status_changed: 'bg-green-500',
|
||||
submission_created: 'bg-orange-500',
|
||||
submission_reviewed: 'bg-green-500',
|
||||
comment_added: 'bg-gray-500'
|
||||
}
|
||||
return colorMap[type] || 'bg-gray-500'
|
||||
}
|
||||
|
||||
function getActivityTitle(type: ActivityType): string {
|
||||
const titleMap: Record<string, string> = {
|
||||
task_created: 'Task Created',
|
||||
task_updated: 'Task Updated',
|
||||
task_assigned: 'Task Assigned',
|
||||
task_status_changed: 'Status Changed',
|
||||
submission_created: 'Work Submitted',
|
||||
submission_reviewed: 'Submission Reviewed',
|
||||
comment_added: 'Comment Added'
|
||||
}
|
||||
return titleMap[type] || 'Activity'
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@
|
||||
:selected-asset="selectedAsset"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:is-columns-locked="lockColumns"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:category-filter="handleCategoryFilterChange"
|
||||
@update:search="searchQuery = $event"
|
||||
@@ -23,6 +24,7 @@
|
||||
@update:show-thumbnails="handleThumbnailToggle"
|
||||
@task-status-filter-changed="handleTaskStatusFilter"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
@toggle-column-lock="toggleColumnLock"
|
||||
@create-asset="showCreateDialog = true"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center py-4">
|
||||
@@ -109,6 +111,7 @@
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
:lock-columns="lockColumns"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@@ -161,26 +164,14 @@
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
|
||||
action cannot be undone and will remove all associated tasks.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@click="handleDeleteAsset"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete Asset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AssetDeleteConfirmDialog
|
||||
v-if="deletionInfo && assetToDelete"
|
||||
:open="showDeleteDialog"
|
||||
:asset-id="assetToDelete.id"
|
||||
:asset-name="deletionInfo.asset_name"
|
||||
@update:open="showDeleteDialog = $event"
|
||||
@confirm-delete="handleDeleteAsset"
|
||||
/>
|
||||
</div>
|
||||
<!-- Asset Detail Panel (Desktop) with slide animation -->
|
||||
<Transition
|
||||
@@ -191,21 +182,27 @@
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadAssets"
|
||||
/>
|
||||
<AssetDetailPanel
|
||||
v-else
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
:all-task-types="allTaskTypes"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -213,18 +210,23 @@
|
||||
<!-- Asset Detail Panel (Mobile) -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadAssets"
|
||||
/>
|
||||
<AssetDetailPanel
|
||||
v-if="selectedAsset"
|
||||
v-else-if="selectedAsset"
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
:all-task-types="allTaskTypes"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -251,20 +253,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import AssetCard from "./AssetCard.vue";
|
||||
import AssetForm from "./AssetForm.vue";
|
||||
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
||||
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
|
||||
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
|
||||
import AssetsDataTable from "./AssetsDataTable.vue";
|
||||
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
||||
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
||||
@@ -273,11 +267,13 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { useTaskStatusesStore } from "@/stores/taskStatuses";
|
||||
import { useDetailPanel } from "@/composables/useDetailPanel";
|
||||
import {
|
||||
assetService,
|
||||
AssetCategory,
|
||||
TaskStatus,
|
||||
type Asset,
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
type AssetDeletionInfo,
|
||||
} from "@/services/asset";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
|
||||
@@ -385,6 +381,9 @@ const showDeleteDialog = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const assetToDelete = ref<Asset | null>(null);
|
||||
const deletionInfo = ref<AssetDeletionInfo | null>(null);
|
||||
|
||||
const taskStatusFilter = ref('')
|
||||
|
||||
// Thumbnail display state - with session storage
|
||||
@@ -392,6 +391,13 @@ const showThumbnails = ref(
|
||||
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
|
||||
);
|
||||
|
||||
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
|
||||
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
|
||||
const toggleColumnLock = () => {
|
||||
lockColumns.value = !lockColumns.value;
|
||||
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const assets = computed(() => assetsStore.assets);
|
||||
const isLoading = computed(() => assetsStore.isLoading);
|
||||
@@ -451,6 +457,7 @@ const assetColumns = computed(() => {
|
||||
onDelete: deleteAsset,
|
||||
onViewTasks: viewAssetTasks,
|
||||
onTaskStatusUpdated: handleTaskStatusUpdate,
|
||||
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||
getSelectedCount: () => selectedCount.value,
|
||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
||||
@@ -563,14 +570,25 @@ const editAsset = (asset: Asset) => {
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteAsset = (asset: Asset) => {
|
||||
selectedAsset.value = asset;
|
||||
showDeleteDialog.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) => {
|
||||
// TODO: Navigate to asset tasks view
|
||||
console.log("View tasks for asset:", asset.name);
|
||||
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
|
||||
selectAsset(asset);
|
||||
};
|
||||
|
||||
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
@@ -619,15 +637,21 @@ const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
if (!selectedAsset.value) return;
|
||||
if (!assetToDelete.value) return;
|
||||
|
||||
try {
|
||||
await assetsStore.deleteAsset(selectedAsset.value.id);
|
||||
await assetsStore.deleteAsset(assetToDelete.value.id);
|
||||
|
||||
showDeleteDialog.value = false;
|
||||
selectedAsset.value = null;
|
||||
assetToDelete.value = null;
|
||||
|
||||
const taskCount = deletionInfo.value?.task_count || 0;
|
||||
deletionInfo.value = null;
|
||||
toast({
|
||||
title: "Asset deleted",
|
||||
description: "Asset has been deleted successfully.",
|
||||
description: taskCount > 0
|
||||
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
|
||||
: "Asset has been deleted successfully.",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -660,6 +684,24 @@ const handleTaskStatusUpdate = async (
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
|
||||
// Update local state instead of reloading all assets
|
||||
const asset = assetsStore.assets.find((a) => a.id === assetId);
|
||||
if (asset && asset.task_details) {
|
||||
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
|
||||
if (taskDetail) {
|
||||
taskDetail.assigned_user_id = userId ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Task assignment updated",
|
||||
description: userId
|
||||
? `${formatTaskType(taskType)} task assigned successfully.`
|
||||
: `${formatTaskType(taskType)} task unassigned successfully.`,
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = "all";
|
||||
searchQuery.value = "";
|
||||
@@ -677,30 +719,19 @@ const formatTaskType = (taskType: string) => {
|
||||
};
|
||||
|
||||
// Detail panel event handlers
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this asset
|
||||
console.log('Create task for asset:', selectedAsset.value?.name);
|
||||
const selectedTaskId = ref<number | null>(null);
|
||||
const selectedTaskTab = ref<string>('infos');
|
||||
|
||||
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||
selectedTaskId.value = task.id;
|
||||
selectedTaskTab.value = tab || 'infos';
|
||||
};
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Open task detail panel
|
||||
console.log('Select task:', task);
|
||||
};
|
||||
|
||||
const handleCreateNote = () => {
|
||||
// TODO: Open note creation dialog
|
||||
console.log('Create note for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handleUploadReference = () => {
|
||||
// TODO: Open reference upload dialog
|
||||
console.log('Upload reference for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handlePublishVersion = () => {
|
||||
// TODO: Open version publish dialog
|
||||
console.log('Publish version for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
// Reset the task sub-panel whenever the asset selection changes (including close)
|
||||
watch(selectedAsset, () => {
|
||||
selectedTaskId.value = null;
|
||||
selectedTaskTab.value = 'infos';
|
||||
});
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="font-medium text-destructive">Failed to load deletion information</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
|
||||
</div>
|
||||
<Alert v-else-if="loadError" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertTitle>Failed to load deletion information</AlertTitle>
|
||||
<AlertDescription>{{ loadError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Deletion Information -->
|
||||
<div v-else-if="deletionInfo" class="space-y-4">
|
||||
@@ -73,59 +71,56 @@
|
||||
</div>
|
||||
|
||||
<!-- Affected Users -->
|
||||
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Users class="h-4 w-4 text-orange-600" />
|
||||
<span class="font-medium text-orange-800">
|
||||
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-orange-700 mb-3">
|
||||
The following users have work associated with this asset that will be marked as deleted:
|
||||
</p>
|
||||
<Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
|
||||
<Users class="h-4 w-4 text-orange-600" />
|
||||
<AlertTitle class="text-orange-800">
|
||||
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
|
||||
</AlertTitle>
|
||||
<AlertDescription class="text-orange-700">
|
||||
<p class="mb-3">
|
||||
The following users have work associated with this asset that will be marked as deleted:
|
||||
</p>
|
||||
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="user in deletionInfo.affected_users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-2 bg-white rounded border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.last_activity_date" class="mt-1">
|
||||
Last active: {{ formatDate(user.last_activity_date) }}
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="user in deletionInfo.affected_users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-2 bg-white rounded border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} • {{ user.role }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.last_activity_date" class="mt-1">
|
||||
Last active: {{ formatDate(user.last_activity_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- No affected users -->
|
||||
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<span class="text-sm text-green-800">No users will be affected by this deletion.</span>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-else variant="default" class="border-green-200 bg-green-50">
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<AlertDescription class="text-green-800">
|
||||
No users will be affected by this deletion.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Data Preservation Notice -->
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Shield class="h-4 w-4 text-blue-600" />
|
||||
<span class="font-medium text-blue-800">Data Preservation</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700">
|
||||
All data will be preserved in the database and can be recovered by administrators.
|
||||
<Alert variant="default" class="border-blue-200 bg-blue-50">
|
||||
<Shield class="h-4 w-4 text-blue-600" />
|
||||
<AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
|
||||
<AlertDescription class="text-blue-700">
|
||||
All data will be preserved in the database and can be recovered by administrators.
|
||||
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Confirmation input -->
|
||||
<div class="space-y-2">
|
||||
@@ -189,6 +184,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/ui/alert'
|
||||
import { assetService, type AssetDeletionInfo } from '@/services/asset'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
@@ -275,7 +275,7 @@ watch(() => props.open, (newOpen) => {
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
@@ -117,8 +117,111 @@
|
||||
|
||||
<!-- Task Status & Assignees -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Add Task -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
|
||||
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
All task types already added
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="type in availableTaskTypes"
|
||||
:key="type"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
:disabled="isCreatingTask"
|
||||
@click="handleAddTask(type)"
|
||||
>
|
||||
{{ formatTaskType(type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Add Note (picks a task, opens its Notes tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
|
||||
<MessageSquarePlus class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'notes')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'attachments')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Publish Version (picks a task, opens its Submissions tab) -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'submissions')"
|
||||
>
|
||||
{{ task.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Tasks -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -182,17 +285,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, X
|
||||
import {
|
||||
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import AssetNotes from './AssetNotes.vue'
|
||||
import AssetReferences from './AssetReferences.vue'
|
||||
|
||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@@ -208,16 +313,13 @@ interface Task {
|
||||
interface Props {
|
||||
projectId: number
|
||||
assetId: number
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', asset: Asset): void
|
||||
(e: 'delete', asset: Asset): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'publish-version'): void
|
||||
(e: 'select-task', task: Task, tab?: string): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
@@ -233,6 +335,7 @@ const notes = ref<any[]>([])
|
||||
const references = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const tasks = computed(() => {
|
||||
@@ -266,6 +369,11 @@ const progressPercentage = computed(() => {
|
||||
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
||||
})
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
||||
})
|
||||
|
||||
const taskStatusCounts = computed(() => {
|
||||
const counts = {
|
||||
not_started: 0,
|
||||
@@ -365,6 +473,18 @@ const loadReferences = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTask = async (taskType: string) => {
|
||||
isCreatingTask.value = true
|
||||
try {
|
||||
await taskService.createAssetTask(props.assetId, taskType)
|
||||
await loadAssetDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
isCreatingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatCategory = (category: string) => {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||
}
|
||||
|
||||
@@ -199,6 +199,19 @@
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Lock First Columns Toggle Button (only for list view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'list'"
|
||||
@click="$emit('toggle-column-lock')"
|
||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
|
||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||
>
|
||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||
<Unlock v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
@@ -248,9 +261,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
import {
|
||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
|
||||
Lock, Unlock
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -290,6 +304,7 @@ interface Props {
|
||||
selectedAsset: Asset | null
|
||||
isDetailPanelEnabled: boolean
|
||||
showThumbnails: boolean
|
||||
isColumnsLocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -302,6 +317,7 @@ const emit = defineEmits<{
|
||||
'update:show-thumbnails': [value: boolean]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'toggle-column-lock': []
|
||||
'create-asset': []
|
||||
}>()
|
||||
|
||||
|
||||
@@ -1,7 +1,105 @@
|
||||
<template>
|
||||
<div class="space-y-4 px-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||
<template v-if="lockColumns">
|
||||
<div v-if="hasRows" class="flex">
|
||||
<!-- Left pane: frozen columns, no horizontal scroll -->
|
||||
<div class="flex-shrink-0 border-r">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in frozenHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:style="frozenWidth(header.column.id)"
|
||||
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in frozenCells(row)"
|
||||
:key="cell.id"
|
||||
:style="frozenWidth(cell.column.id)"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: movable columns, own horizontal scrollbar -->
|
||||
<div class="flex-1 min-w-0 overflow-x-auto">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in movableHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'actions' ? 'w-12' : '',
|
||||
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in movableCells(row)"
|
||||
:key="cell.id"
|
||||
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No results.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unlocked: standard single table -->
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
@@ -31,7 +129,7 @@
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
:class="{
|
||||
'bg-muted/30': row.getIsSelected(),
|
||||
'table-row-selectable': true,
|
||||
'selecting': isRangeSelecting
|
||||
@@ -67,13 +165,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type HeaderGroup,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
@@ -93,10 +193,38 @@ interface Props {
|
||||
sorting: SortingState
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
lockColumns?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Frozen (locked) columns and their fixed widths (px), in column order.
|
||||
const FROZEN: Record<string, number> = {
|
||||
select: 48,
|
||||
thumbnail: 96,
|
||||
name: 200,
|
||||
}
|
||||
|
||||
const isFrozen = (id: string) => id in FROZEN
|
||||
|
||||
const frozenWidth = (id: string) => {
|
||||
const w = FROZEN[id]
|
||||
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
|
||||
}
|
||||
|
||||
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
|
||||
// respect column visibility; frozen ones are always shown.
|
||||
const frozenHeaders = (group: HeaderGroup<Asset>) =>
|
||||
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const movableHeaders = (group: HeaderGroup<Asset>) =>
|
||||
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const frozenCells = (row: Row<Asset>) =>
|
||||
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
|
||||
const movableCells = (row: Row<Asset>) =>
|
||||
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
|
||||
|
||||
const hasRows = computed(() => table.getRowModel().rows.length > 0)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Global Task Columns Toggle Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllTaskColumns"
|
||||
class="h-9"
|
||||
>
|
||||
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
|
||||
<ListX v-else class="h-4 w-4 mr-2" />
|
||||
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Column Visibility Dropdown -->
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.thumbnail"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('thumbnail', val)"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.name"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('name', val)"
|
||||
/>
|
||||
<span>Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="category" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.category"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('category', val)"
|
||||
/>
|
||||
<span>Category</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.status"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('status', val)"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<!-- Standard Task Types -->
|
||||
<SelectItem value="modeling" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.modeling"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('modeling', val)"
|
||||
/>
|
||||
<span>Modeling</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="surfacing" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.surfacing"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('surfacing', val)"
|
||||
/>
|
||||
<span>Surfacing</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="rigging" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.rigging"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('rigging', val)"
|
||||
/>
|
||||
<span>Rigging</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<!-- Custom Task Types -->
|
||||
<SelectItem
|
||||
v-for="customType in customTaskTypes"
|
||||
:key="customType"
|
||||
:value="customType"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns[customType]"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn(customType, val)"
|
||||
/>
|
||||
<span>{{ formatTaskType(customType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other Columns</SelectLabel>
|
||||
<SelectItem value="description" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.description"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('description', val)"
|
||||
/>
|
||||
<span>Description</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="updatedAt" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.updatedAt"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('updatedAt', val)"
|
||||
/>
|
||||
<span>Updated</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Columns, ListTodo, ListX } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface Props {
|
||||
visibleColumns: Record<string, boolean>;
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:visibleColumns", columns: Record<string, boolean>): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
const customTaskTypes = ref<string[]>([])
|
||||
const savedTaskColumnStates = ref<Record<string, boolean>>({})
|
||||
|
||||
// Standard task types
|
||||
const standardTaskTypes = ['modeling', 'surfacing', 'rigging']
|
||||
|
||||
// All task types (standard + custom)
|
||||
const allTaskTypes = computed(() => [...standardTaskTypes, ...customTaskTypes.value])
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
return allTaskTypes.value.every(taskType => props.visibleColumns[taskType])
|
||||
})
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const { projectService } = await import('@/services/project')
|
||||
const project = await projectService.getProject(props.projectId)
|
||||
customTaskTypes.value = project.custom_asset_task_types || []
|
||||
|
||||
// Initialize visibility for custom task types if not already set
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
let hasChanges = false
|
||||
|
||||
for (const customType of customTaskTypes.value) {
|
||||
if (!(customType in newColumns)) {
|
||||
newColumns[customType] = true // Show custom types by default
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load custom task types:', error)
|
||||
// Continue without custom task types
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle all task columns show/hide
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
|
||||
if (allTaskColumnsVisible.value) {
|
||||
// Hide all task columns but save their states
|
||||
savedTaskColumnStates.value = {}
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
savedTaskColumnStates.value[taskType] = newColumns[taskType]
|
||||
newColumns[taskType] = false
|
||||
}
|
||||
} else {
|
||||
// Restore saved states or show all
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
if (taskType in savedTaskColumnStates.value) {
|
||||
newColumns[taskType] = savedTaskColumnStates.value[taskType]
|
||||
} else {
|
||||
newColumns[taskType] = true
|
||||
}
|
||||
}
|
||||
savedTaskColumnStates.value = {}
|
||||
}
|
||||
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
|
||||
const handleColumnToggle = () => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean | 'indeterminate') => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
newColumns[column] = checked === true
|
||||
emit('update:visibleColumns', newColumns)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomTaskTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -1,33 +1,27 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
>
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
<div class="relative flex items-center gap-1">
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
:disabled="isUpdating || isLoadingStatuses"
|
||||
|
||||
|
||||
>
|
||||
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
|
||||
:style="{ backgroundColor: currentStatusObject.color }"
|
||||
>
|
||||
<SelectValue
|
||||
<SelectValue
|
||||
:model-value="currentStatusId"
|
||||
>
|
||||
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
|
||||
<!-- console.log(currentStatusObject) -->
|
||||
|
||||
<!-- currentStatusObject -->
|
||||
</SelectValue>
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
:value="statusOption.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Color indicator -->
|
||||
<div
|
||||
<div
|
||||
v-if="statusOption.color"
|
||||
class="w-3 h-3 rounded-full border border-border"
|
||||
:style="{ backgroundColor: statusOption.color }"
|
||||
@@ -37,10 +31,116 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<!-- User Assignment Button -->
|
||||
<div @click.stop>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-accent relative"
|
||||
:disabled="isUpdating"
|
||||
@click.stop="ensureMembersLoaded"
|
||||
>
|
||||
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<User class="h-3 w-3" v-else />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
|
||||
<div class="space-y-2">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||
|
||||
<!-- Current Assignment Display with X button -->
|
||||
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground">Current</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
@click.stop="handleAssignUser(null)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
class="pl-7 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span class="ml-2 text-sm">Loading members...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No project members found
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
@click="loadProjectMembers"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content when members are loaded -->
|
||||
<template v-else>
|
||||
<!-- Project members list -->
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
|
||||
No matching members found
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
v-for="member in filteredProjectMembers"
|
||||
:key="member.user_id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start h-10"
|
||||
@click="handleAssignUser(member.user_id)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<Avatar class="h-8 w-8 mr-2">
|
||||
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||
</div>
|
||||
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
@@ -57,11 +157,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -76,19 +187,47 @@ interface Props {
|
||||
status: TaskStatus | string
|
||||
taskId?: number | null
|
||||
projectId: number
|
||||
assignedUserId?: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
|
||||
(e: 'assignment-updated', assetId: number, taskType: string, userId: number | null): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filtered project members based on search query
|
||||
const filteredProjectMembers = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return projectMembers.value
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return projectMembers.value.filter(member => {
|
||||
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
|
||||
const departmentRole = member.department_role?.toLowerCase() || ''
|
||||
return fullName.includes(query) || departmentRole.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
// Get assigned user info
|
||||
const assignedUserId = computed(() => props.assignedUserId)
|
||||
const assignedUser = computed(() => {
|
||||
if (!assignedUserId.value) return null
|
||||
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
|
||||
})
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
@@ -144,10 +283,22 @@ const formatStatusName = (status: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// Format department role for display
|
||||
const formatDepartmentRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
const getUserInitials = (member: ProjectMember): string => {
|
||||
const first = member.user_first_name?.charAt(0) || ''
|
||||
const last = member.user_last_name?.charAt(0) || ''
|
||||
return (first + last).toUpperCase()
|
||||
}
|
||||
|
||||
// Fetch custom statuses for the project using store
|
||||
const fetchStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
@@ -155,6 +306,62 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
} finally {
|
||||
isLoadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure members are loaded when popover is about to open
|
||||
const ensureMembersLoaded = () => {
|
||||
if (projectMembers.value.length === 0) {
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId: number | null) => {
|
||||
isAssigning.value = true
|
||||
|
||||
try {
|
||||
let taskId = props.taskId
|
||||
|
||||
// If no task exists, create one first
|
||||
if (!taskId) {
|
||||
const newTask = await taskService.createAssetTask(props.assetId, props.taskType)
|
||||
taskId = newTask.task_id
|
||||
}
|
||||
|
||||
// Assign or unassign the task
|
||||
if (taskId) {
|
||||
if (userId) {
|
||||
// Use the assignment endpoint for assigning to a user
|
||||
await taskService.assignTask(taskId, userId)
|
||||
} else {
|
||||
// Use the update endpoint for unassignment (set assigned_user_id to 0)
|
||||
await taskService.updateTask(taskId, { assigned_user_id: 0 })
|
||||
}
|
||||
emit('assignment-updated', props.assetId, props.taskType, userId)
|
||||
}
|
||||
|
||||
// Close popover by simulating click outside after assignment
|
||||
setTimeout(() => {
|
||||
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to assign task:', error)
|
||||
} finally {
|
||||
isAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatusId: any) => {
|
||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||
|
||||
@@ -189,10 +396,14 @@ const handleStatusChange = async (newStatusId: any) => {
|
||||
// Fetch statuses on mount
|
||||
onMounted(() => {
|
||||
fetchStatuses()
|
||||
// Preload project members to ensure they're available when needed
|
||||
loadProjectMembers()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
})
|
||||
</script>
|
||||
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
|
||||
onDelete: (asset: Asset) => void
|
||||
onViewTasks: (asset: Asset) => void
|
||||
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
||||
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
|
||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||
@@ -225,8 +226,10 @@ export const createAssetColumns = (
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id
|
||||
|
||||
const taskDetail = asset.task_details?.find(t => t.task_type === taskType)
|
||||
const taskId = taskDetail?.task_id
|
||||
const assignedUserId = taskDetail?.assigned_user_id || null
|
||||
|
||||
return h(EditableTaskStatus, {
|
||||
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
||||
assetId: asset.id,
|
||||
@@ -234,9 +237,13 @@ export const createAssetColumns = (
|
||||
status,
|
||||
taskId,
|
||||
projectId: meta.projectId,
|
||||
assignedUserId,
|
||||
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
|
||||
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
|
||||
},
|
||||
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
|
||||
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
|
||||
},
|
||||
})
|
||||
},
|
||||
enableSorting: true,
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Upload Example</CardTitle>
|
||||
<CardDescription>
|
||||
Example showing how to integrate upload limit display and validation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Upload Limit Display -->
|
||||
<UploadLimitDisplay />
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-2">
|
||||
<Label for="file-upload">Select File</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
:disabled="uploading"
|
||||
accept=".mov,.mp4,.avi,.mkv,.webm,.jpg,.jpeg,.png,.exr,.tiff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div v-if="selectedFile" class="text-sm space-y-1">
|
||||
<p><strong>File:</strong> {{ selectedFile.name }}</p>
|
||||
<p><strong>Size:</strong> {{ formatFileSize(selectedFile.size) }}</p>
|
||||
<p><strong>Type:</strong> {{ getFileType(selectedFile.name) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation Errors -->
|
||||
<div v-if="validationError" class="text-sm text-destructive">
|
||||
{{ validationError }}
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<Button
|
||||
@click="uploadFile"
|
||||
:disabled="!selectedFile || !!validationError || uploading"
|
||||
class="w-full"
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 animate-spin mr-2" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
{{ uploading ? 'Uploading...' : 'Upload File' }}
|
||||
</Button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="uploadSuccess" class="text-sm text-green-600">
|
||||
File uploaded successfully!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Upload, Loader2 } from 'lucide-vue-next'
|
||||
import UploadLimitDisplay from '@/components/settings/UploadLimitDisplay.vue'
|
||||
import { validateFile, formatFileSize, isMovieFile, isImageFile } from '@/utils/fileValidation'
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const validationError = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const uploadSuccess = ref(false)
|
||||
|
||||
function getFileType(fileName: string): string {
|
||||
if (isMovieFile(fileName)) return 'Movie'
|
||||
if (isImageFile(fileName)) return 'Image'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
selectedFile.value = file
|
||||
uploadSuccess.value = false
|
||||
|
||||
// Validate file
|
||||
const result = await validateFile(file)
|
||||
validationError.value = result.isValid ? null : result.error || 'Invalid file'
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value || validationError.value) return
|
||||
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
// Simulate upload delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Here you would make the actual API call to upload the file
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', selectedFile.value)
|
||||
// await api.post('/tasks/123/attachments', formData)
|
||||
|
||||
uploadSuccess.value = true
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
|
||||
} catch (error) {
|
||||
validationError.value = 'Upload failed. Please try again.'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -119,7 +119,6 @@ import {
|
||||
Key,
|
||||
Database,
|
||||
BarChart3,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
@@ -183,8 +182,7 @@ const developerItems = computed(() => [
|
||||
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
|
||||
{ title: 'All Projects', url: '/developer/projects', icon: Database },
|
||||
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
|
||||
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }
|
||||
])
|
||||
|
||||
// Mock recent projects - this would come from a store in real implementation
|
||||
|
||||
@@ -65,26 +65,11 @@
|
||||
<User class="size-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
|
||||
<Palette class="size-4" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleNotifications">
|
||||
<Bell class="size-4" />
|
||||
Notifications
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ notificationsEnabled ? 'On' : 'Off' }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
|
||||
<!-- Help and support -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/help')">
|
||||
<HelpCircle class="size-4" />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="showKeyboardShortcuts">
|
||||
<Keyboard class="size-4" />
|
||||
Keyboard Shortcuts
|
||||
@@ -99,6 +84,24 @@
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog v-model:open="showShortcutsDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Toggle sidebar</span>
|
||||
<span class="font-mono">⌘/Ctrl + B</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Quick search</span>
|
||||
<span class="font-mono">⌘/Ctrl + K</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -111,11 +114,8 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Keyboard,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
@@ -132,6 +132,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@@ -181,8 +187,7 @@ const showRoleFeatures = computed(() => {
|
||||
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
|
||||
})
|
||||
|
||||
// Notifications state (this would typically come from a notifications store)
|
||||
const notificationsEnabled = ref(true)
|
||||
const showShortcutsDialog = ref(false)
|
||||
|
||||
// Actions
|
||||
const navigateTo = (path: string) => {
|
||||
@@ -198,24 +203,8 @@ const handleLogout = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
notificationsEnabled.value = !notificationsEnabled.value
|
||||
// In a real app, this would update user preferences
|
||||
console.log('Notifications toggled:', notificationsEnabled.value)
|
||||
}
|
||||
|
||||
const showKeyboardShortcuts = () => {
|
||||
// In a real app, this would open a modal with keyboard shortcuts
|
||||
console.log('Keyboard shortcuts modal would open here')
|
||||
// For now, just show an alert with some common shortcuts
|
||||
alert(`Keyboard Shortcuts:
|
||||
|
||||
⌘/Ctrl + B - Toggle sidebar
|
||||
⌘/Ctrl + K - Quick search
|
||||
⌘/Ctrl + , - Open preferences
|
||||
⌘/Ctrl + / - Show help
|
||||
|
||||
More shortcuts available in the help documentation.`)
|
||||
showShortcutsDialog.value = true
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,14 +41,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
v-if="member.user_id"
|
||||
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -65,21 +58,21 @@
|
||||
<!-- Department Role -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm">Department:</Label>
|
||||
<select
|
||||
:value="member.department_role || 'none'"
|
||||
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
|
||||
<Select
|
||||
:model-value="member.department_role || 'none'"
|
||||
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
|
||||
:disabled="isUpdatingMember === member.id"
|
||||
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Joined Date -->
|
||||
@@ -89,7 +82,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -118,119 +111,84 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="closeAddDialog"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Add Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<!-- Add Member Dialog -->
|
||||
<Dialog v-model:open="showAddMemberDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a user to this project and assign their department role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- User Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label for="user">User</Label>
|
||||
<select
|
||||
v-model="newMember.userId"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
<option
|
||||
v-for="user in (availableUsers || [])"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
<Label>User</Label>
|
||||
<Select v-model="newMember.userId" :disabled="isAddingMember">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Department Role -->
|
||||
<div class="space-y-2">
|
||||
<Label for="department">Department Role (Optional)</Label>
|
||||
<select
|
||||
v-model="newMember.departmentRole"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
<Label>Department Role (Optional)</Label>
|
||||
<Select v-model="newMember.departmentRole" :disabled="isAddingMember">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="closeAddDialog"
|
||||
:disabled="isAddingMember"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.userId || isAddingMember"
|
||||
>
|
||||
<Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
|
||||
<div v-if="isAddingMember" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span>Adding...</span>
|
||||
</div>
|
||||
<span v-else>Add Member</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Remove Member Confirmation -->
|
||||
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="showRemoveDialog = false"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Remove Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<AlertDialog v-model:open="showRemoveDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="showRemoveDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmRemoveMember"
|
||||
variant="destructive"
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -242,14 +200,39 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { userService } from '@/services/user'
|
||||
import type { User } from '@/types/auth'
|
||||
@@ -259,10 +242,26 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
const departmentRoles = [
|
||||
{ value: 'layout', label: 'Layout' },
|
||||
{ value: 'animation', label: 'Animation' },
|
||||
{ value: 'lighting', label: 'Lighting' },
|
||||
{ value: 'composite', label: 'Composite' },
|
||||
{ value: 'modeling', label: 'Modeling' },
|
||||
{ value: 'rigging', label: 'Rigging' },
|
||||
{ value: 'surfacing', label: 'Surfacing' },
|
||||
]
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const availableUsers = ref<User[]>([])
|
||||
const allUsers = ref<User[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isUpdatingMember = ref<number | null>(null)
|
||||
@@ -273,7 +272,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
|
||||
|
||||
const newMember = ref({
|
||||
userId: '',
|
||||
departmentRole: ''
|
||||
departmentRole: 'none'
|
||||
})
|
||||
|
||||
// Computed
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map(m => m.user_id))
|
||||
return allUsers.value.filter(user => user.is_approved && !memberUserIds.has(user.id))
|
||||
})
|
||||
|
||||
// Methods
|
||||
@@ -284,40 +289,50 @@ const loadMembers = async () => {
|
||||
members.value = await projectService.getProjectMembers(props.projectId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load members'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load project members',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvailableUsers = async () => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const allUsers = await userService.getAllUsers()
|
||||
// Filter out users who are already members
|
||||
const memberUserIds = new Set(members.value.map(m => m.user_id))
|
||||
availableUsers.value = allUsers.filter(user =>
|
||||
user.is_approved && !memberUserIds.has(user.id)
|
||||
)
|
||||
allUsers.value = await userService.getUsers()
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
error.value = 'Failed to load available users'
|
||||
availableUsers.value = [] // Ensure it's always an array
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load users',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
|
||||
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
|
||||
try {
|
||||
isUpdatingMember.value = memberId
|
||||
await projectService.updateProjectMember(props.projectId, memberId, {
|
||||
isUpdatingMember.value = member.id
|
||||
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
|
||||
department_role: departmentRole as any
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const member = members.value.find(m => m.id === memberId)
|
||||
if (member) {
|
||||
member.department_role = departmentRole as any
|
||||
|
||||
const index = members.value.findIndex(m => m.id === member.id)
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Role updated',
|
||||
description: 'Member department role has been updated',
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update member role'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update member role',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isUpdatingMember.value = null
|
||||
}
|
||||
@@ -330,18 +345,24 @@ const addMember = async () => {
|
||||
isAddingMember.value = true
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.userId),
|
||||
department_role: newMember.value.departmentRole || undefined
|
||||
department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
|
||||
}
|
||||
|
||||
|
||||
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
|
||||
members.value.push(addedMember)
|
||||
|
||||
|
||||
closeAddDialog()
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
|
||||
toast({
|
||||
title: 'Member added',
|
||||
description: 'Team member has been added to the project',
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to add member'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to add member',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isAddingMember.value = false
|
||||
}
|
||||
@@ -357,20 +378,25 @@ const confirmRemoveMember = async () => {
|
||||
|
||||
try {
|
||||
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
|
||||
|
||||
// Remove from local state
|
||||
|
||||
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1)
|
||||
}
|
||||
|
||||
|
||||
toast({
|
||||
title: 'Member removed',
|
||||
description: 'Team member has been removed from the project',
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to remove member',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
showRemoveDialog.value = false
|
||||
memberToRemove.value = null
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to remove member'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +404,6 @@ const getUserInitials = (member: ProjectMember) => {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -391,25 +413,17 @@ const formatDate = (dateString: string) => {
|
||||
}
|
||||
|
||||
const openAddDialog = async () => {
|
||||
try {
|
||||
await loadAvailableUsers()
|
||||
showAddMemberDialog.value = true
|
||||
} catch (err) {
|
||||
console.error('Failed to open add dialog:', err)
|
||||
error.value = 'Failed to load user list'
|
||||
}
|
||||
await loadUsers()
|
||||
showAddMemberDialog.value = true
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddMemberDialog.value = false
|
||||
// Reset form
|
||||
newMember.value = { userId: '', departmentRole: '' }
|
||||
newMember.value = { userId: '', departmentRole: 'none' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Add Member Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">Add Team Member</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Select
|
||||
:model-value="newMember.user_id"
|
||||
@update:model-value="newMember.user_id = $event"
|
||||
class="flex-1"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="newMember.department_role"
|
||||
@update:model-value="newMember.department_role = $event"
|
||||
class="w-40"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.user_id || isLoading"
|
||||
size="sm"
|
||||
>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Current Members -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">
|
||||
Current Members ({{ members.length }})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading && members.length === 0"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">Loading members...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
<User class="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ member.user_email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
:model-value="member.department_role || ''"
|
||||
@update:model-value="(value) => updateMemberRole(member, value)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="removeMember(member)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No members assigned to this project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<Button @click="$emit('close')" variant="outline"> Close </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { Plus, User, Users, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import { projectService, type ProjectMember } from "@/services/project";
|
||||
import { userService } from "@/services/user";
|
||||
import type { User as UserType } from "@/types/auth";
|
||||
import type { Project } from "@/stores/projects";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([]);
|
||||
const allUsers = ref<UserType[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const newMember = ref({
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
});
|
||||
|
||||
// Computed
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadMembers = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
members.value = await projectService.getProjectMembers(props.project.id);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load project members",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
allUsers.value = await userService.getUsers();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load users",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember.value.user_id) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.user_id),
|
||||
department_role: newMember.value.department_role || undefined,
|
||||
};
|
||||
|
||||
const addedMember = await projectService.addProjectMember(
|
||||
props.project.id,
|
||||
memberData
|
||||
);
|
||||
members.value.push(addedMember);
|
||||
|
||||
// Reset form
|
||||
newMember.value = {
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
};
|
||||
|
||||
toast({
|
||||
title: "Member added",
|
||||
description: "Team member has been added to the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to add member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const updatedMember = await projectService.updateProjectMember(
|
||||
props.project.id,
|
||||
member.id,
|
||||
{ department_role: newRole || undefined }
|
||||
);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Role updated",
|
||||
description: "Member department role has been updated",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update member role",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member: ProjectMember) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await projectService.removeProjectMember(props.project.id, member.id);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Member removed",
|
||||
description: "Team member has been removed from the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers();
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
@@ -102,49 +102,31 @@ const tabs = computed<Tab[]>(() => [
|
||||
// Determine active tab based on current route
|
||||
const activeTab = computed(() => {
|
||||
const currentPath = route.path;
|
||||
|
||||
// Debug logging
|
||||
console.log('ProjectTabs - Current path:', currentPath);
|
||||
console.log('ProjectTabs - Project ID:', props.projectId);
|
||||
|
||||
if (currentPath === `/projects/${props.projectId}`) {
|
||||
console.log('ProjectTabs - Active tab: overview');
|
||||
return "overview";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
|
||||
console.log('ProjectTabs - Active tab: shots');
|
||||
return "shots";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
|
||||
console.log('ProjectTabs - Active tab: assets');
|
||||
return "assets";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
|
||||
console.log('ProjectTabs - Active tab: tasks');
|
||||
return "tasks";
|
||||
} else if (
|
||||
currentPath.startsWith(`/projects/${props.projectId}/settings`)
|
||||
) {
|
||||
console.log('ProjectTabs - Active tab: settings');
|
||||
return "settings";
|
||||
}
|
||||
|
||||
console.log('ProjectTabs - Active tab: overview (default)');
|
||||
return "overview";
|
||||
});
|
||||
|
||||
// Set active tab and navigate
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const tab = tabs.value.find((t) => t.id === tabId);
|
||||
console.log('ProjectTabs - Setting active tab:', tabId);
|
||||
console.log('ProjectTabs - Tab found:', tab);
|
||||
console.log('ProjectTabs - Current route path:', route.path);
|
||||
console.log('ProjectTabs - Target route:', tab?.route);
|
||||
|
||||
if (tab) {
|
||||
console.log('ProjectTabs - Navigating to:', tab.route);
|
||||
router.push(tab.route).catch(err => {
|
||||
console.error('ProjectTabs - Navigation error:', err);
|
||||
router.push(tab.route).catch(() => {
|
||||
// Navigation aborted (e.g. duplicate route) — safe to ignore
|
||||
});
|
||||
} else {
|
||||
console.log('ProjectTabs - Tab not found');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Table Header Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ episodeId ? `Episode ${episodeId} Shots` : "All Shots" }}
|
||||
</h3>
|
||||
<Badge variant="secondary" v-if="shots.length > 0">
|
||||
{{ shots.length }} shot{{ shots.length !== 1 ? "s" : "" }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="refreshShots">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Shot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
|
||||
></div>
|
||||
<span class="text-muted-foreground">Loading shots...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
|
||||
<p class="text-muted-foreground">{{ error }}</p>
|
||||
<Button variant="outline" size="sm" @click="refreshShots" class="mt-2">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="shots.length === 0" class="text-center py-12">
|
||||
<Camera class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 class="text-lg font-semibold mb-2">No shots found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{
|
||||
episodeId
|
||||
? "This episode doesn't have any shots yet."
|
||||
: "No shots found for the selected criteria."
|
||||
}}
|
||||
</p>
|
||||
<Button @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create First Shot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shots Table -->
|
||||
<div v-else class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Shot Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Frames</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Tasks</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead class="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="shot in shots"
|
||||
:key="shot.id"
|
||||
class="hover:bg-muted/50"
|
||||
>
|
||||
<TableCell class="font-medium">{{ shot.name }}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
v-if="shot.description"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ shot.description }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground italic"
|
||||
>No description</span
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="font-mono text-sm">
|
||||
{{ shot.frame_start }}-{{ shot.frame_end }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground ml-2">
|
||||
({{ shot.frame_end - shot.frame_start + 1 }} frames)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getStatusVariant(shot.status)">
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm">{{ shot.task_count }} tasks</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(shot.updated_at) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="editShot(shot)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="viewTasks(shot)">
|
||||
<CheckSquare class="h-4 w-4 mr-2" />
|
||||
View Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="deleteShot(shot)"
|
||||
class="text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import {
|
||||
Camera,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
CheckSquare,
|
||||
Trash2,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { shotService, type Shot, ShotStatus } from "@/services/shot";
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
episodeId?: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Reactive state
|
||||
const shots = ref<Shot[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Methods
|
||||
const loadShots = async () => {
|
||||
if (!props.projectId) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const shotsData = await shotService.getShots(
|
||||
props.projectId,
|
||||
props.episodeId || undefined
|
||||
);
|
||||
shots.value = shotsData;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to load shots";
|
||||
shots.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshShots = () => {
|
||||
loadShots();
|
||||
};
|
||||
|
||||
const createShot = () => {
|
||||
// TODO: Implement shot creation dialog
|
||||
console.log("Create shot for episode:", props.episodeId);
|
||||
};
|
||||
|
||||
const editShot = (shot: Shot) => {
|
||||
// TODO: Implement shot editing
|
||||
console.log("Edit shot:", shot);
|
||||
};
|
||||
|
||||
const viewTasks = (shot: Shot) => {
|
||||
// TODO: Navigate to shot tasks view
|
||||
console.log("View tasks for shot:", shot);
|
||||
};
|
||||
|
||||
const deleteShot = async (shot: Shot) => {
|
||||
// TODO: Implement shot deletion with confirmation
|
||||
console.log("Delete shot:", shot);
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return "secondary";
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return "default";
|
||||
case ShotStatus.ON_HOLD:
|
||||
return "outline";
|
||||
case ShotStatus.COMPLETED:
|
||||
return "default";
|
||||
case ShotStatus.APPROVED:
|
||||
return "default";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => [props.projectId, props.episodeId],
|
||||
() => {
|
||||
loadShots();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadShots();
|
||||
});
|
||||
</script>
|
||||
@@ -428,21 +428,13 @@ const handleDialogSave = async () => {
|
||||
}
|
||||
|
||||
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
|
||||
console.log('=== HANDLE DELETE ===')
|
||||
console.log('Received taskType:', taskType)
|
||||
console.log('Received category:', category)
|
||||
|
||||
taskTypeToDelete.value = taskType
|
||||
categoryToDelete.value = category
|
||||
deleteError.value = ''
|
||||
isDeleteDialogOpen.value = true
|
||||
|
||||
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
|
||||
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
console.log('=== CLOSE DELETE DIALOG ===')
|
||||
isDeleteDialogOpen.value = false
|
||||
// Values will be cleared by @update:open handler
|
||||
}
|
||||
@@ -451,35 +443,22 @@ const confirmDelete = async () => {
|
||||
// Capture values immediately before any async operations
|
||||
const taskTypeToDeleteLocal = taskTypeToDelete.value
|
||||
const categoryToDeleteLocal = categoryToDelete.value
|
||||
|
||||
|
||||
try {
|
||||
isDeleting.value = true
|
||||
deleteError.value = ''
|
||||
|
||||
console.log('=== DELETE DEBUG ===')
|
||||
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
|
||||
console.log('categoryToDelete.value:', categoryToDeleteLocal)
|
||||
console.log('projectId:', props.projectId)
|
||||
|
||||
|
||||
if (!taskTypeToDeleteLocal) {
|
||||
console.error('ERROR: taskTypeToDelete is empty!')
|
||||
deleteError.value = 'Task type name is missing. Please try again.'
|
||||
isDeleting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Deleting task type:', {
|
||||
projectId: props.projectId,
|
||||
taskType: taskTypeToDeleteLocal,
|
||||
category: categoryToDeleteLocal
|
||||
})
|
||||
|
||||
|
||||
const response = await customTaskTypeService.deleteCustomTaskType(
|
||||
props.projectId,
|
||||
taskTypeToDeleteLocal,
|
||||
categoryToDeleteLocal
|
||||
)
|
||||
console.log('Delete task type response:', response)
|
||||
taskTypes.value = response
|
||||
|
||||
toast({
|
||||
|
||||
@@ -169,17 +169,26 @@
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedShot"
|
||||
<div
|
||||
v-if="showPanel && selectedShot"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadShots"
|
||||
/>
|
||||
<ShotDetailPanel
|
||||
v-else
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
:initial-shot="selectedShot"
|
||||
:all-task-types="allTaskTypes"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@close="closeDetailPanel"
|
||||
/>
|
||||
@@ -190,14 +199,22 @@
|
||||
<!-- Mobile Detail Sheet -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTaskId"
|
||||
:key="selectedTaskId"
|
||||
:task-id="selectedTaskId"
|
||||
:initial-tab="selectedTaskTab"
|
||||
@close="selectedTaskId = null"
|
||||
@task-updated="loadShots"
|
||||
/>
|
||||
<ShotDetailPanel
|
||||
v-if="selectedShot"
|
||||
v-else-if="selectedShot"
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
:initial-shot="selectedShot"
|
||||
:all-task-types="allTaskTypes"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
/>
|
||||
</SheetContent>
|
||||
@@ -316,6 +333,7 @@ import ShotCard from './ShotCard.vue'
|
||||
import ShotForm from './ShotForm.vue'
|
||||
import BulkShotForm from './BulkShotForm.vue'
|
||||
import ShotDetailPanel from './ShotDetailPanel.vue'
|
||||
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
||||
import ShotsDataTable from './ShotsDataTable.vue'
|
||||
import ShotTableToolbar from './ShotTableToolbar.vue'
|
||||
import { createShotColumns, type ShotColumnMeta } from './columns'
|
||||
@@ -845,15 +863,19 @@ const clearSearch = () => {
|
||||
loadShots()
|
||||
}
|
||||
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this shot
|
||||
console.log('Create task for shot:', selectedShot.value?.name)
|
||||
const selectedTaskId = ref<number | null>(null)
|
||||
const selectedTaskTab = ref<string>('infos')
|
||||
|
||||
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
||||
selectedTaskId.value = task.id
|
||||
selectedTaskTab.value = tab || 'infos'
|
||||
}
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Navigate to task detail view
|
||||
console.log('View task:', task.name)
|
||||
}
|
||||
// Reset the task sub-panel whenever the shot selection changes (including close)
|
||||
watch(selectedShot, () => {
|
||||
selectedTaskId.value = null
|
||||
selectedTaskTab.value = 'infos'
|
||||
})
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click="toggleColumn('thumbnail')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('thumbnail')"
|
||||
@change="handleCheckboxChange('thumbnail', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click="toggleColumn('name')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('name')"
|
||||
@change="handleCheckboxChange('name', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Shot Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="episode" @click="toggleColumn('episode')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('episode')"
|
||||
@change="handleCheckboxChange('episode', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Episode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click="toggleColumn('status')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('status')"
|
||||
@change="handleCheckboxChange('status', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="taskType in allTaskTypes"
|
||||
:key="taskType"
|
||||
:value="taskType"
|
||||
@click="toggleColumn(taskType)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible(taskType)"
|
||||
@change="handleCheckboxChange(taskType, $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>{{ formatTaskType(taskType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Columns } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
}>()
|
||||
|
||||
// Use global store
|
||||
const columnVisibilityStore = useColumnVisibilityStore()
|
||||
|
||||
// Local reactive state for checkbox visibility
|
||||
const localVisibility = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for store changes and update local state
|
||||
watch(
|
||||
() => columnVisibilityStore.columnVisibility,
|
||||
(newVal) => {
|
||||
localVisibility.value = { ...newVal }
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// Initialize store on mount
|
||||
onMounted(() => {
|
||||
columnVisibilityStore.initialize()
|
||||
})
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
|
||||
const handleColumnToggle = (value: string) => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const isColumnVisible = (columnId: string): boolean => {
|
||||
// If not in visibility state, column is visible by default
|
||||
return localVisibility.value[columnId] !== false
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (column: string, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateColumn(column, target.checked)
|
||||
}
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
updateColumn(column, !isColumnVisible(column))
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean) => {
|
||||
// Always use global store
|
||||
columnVisibilityStore.updateColumn(column, checked)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -258,7 +258,7 @@ watch(() => props.open, (newOpen) => {
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
@@ -131,15 +131,58 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<Button
|
||||
v-if="canCreateTask"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-task')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Popover v-if="canCreateTask">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
|
||||
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
All task types already added
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="type in availableTaskTypes"
|
||||
:key="type"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
:disabled="isCreatingTask"
|
||||
@click="handleAddTask(type)"
|
||||
>
|
||||
{{ formatTaskType(type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'submissions')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Tasks -->
|
||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||
@@ -158,7 +201,7 @@
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
|
||||
@click="$emit('select-task', task)"
|
||||
@click="$emit('select-task', task, 'infos')"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
@@ -178,15 +221,29 @@
|
||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
||||
<Button
|
||||
v-if="canCreateNote"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-note')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
<Popover v-if="canCreateNote">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'notes')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -222,15 +279,29 @@
|
||||
<TabsContent value="references" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||
<Button
|
||||
v-if="canUploadReferences"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('upload-reference')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
<Popover v-if="canUploadReferences">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="$emit('select-task', task, 'attachments')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -279,15 +350,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
@@ -302,16 +375,14 @@ interface Props {
|
||||
projectId: number
|
||||
shotId: number
|
||||
initialShot?: Shot
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', shot: Shot): void
|
||||
(e: 'delete', shot: Shot): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'select-task', task: Task, tab?: string): void
|
||||
(e: 'link-asset'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'edit-design'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
@@ -326,6 +397,7 @@ const shot = ref<Shot | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
@@ -383,6 +455,11 @@ const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadShotDetails = async () => {
|
||||
try {
|
||||
@@ -415,6 +492,20 @@ const loadTasks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTask = async (taskType: string) => {
|
||||
isCreatingTask.value = true
|
||||
try {
|
||||
await taskService.createShotTask(props.shotId, taskType)
|
||||
// Bypass initialShot (which would just return the stale cached object) to get the new task
|
||||
shot.value = await shotService.getShot(props.shotId)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
isCreatingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
|
||||
@@ -93,6 +93,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this note? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,6 +119,16 @@ import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
@@ -121,6 +148,7 @@ const authStore = useAuthStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const editContent = ref('')
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
|
||||
@@ -174,9 +202,11 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this note?')) return
|
||||
function handleDelete() {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await taskService.deleteTaskNote(props.taskId, props.note.id)
|
||||
emit('noteUpdated')
|
||||
@@ -185,12 +215,13 @@ async function handleDelete() {
|
||||
description: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting note:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete note',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,23 @@
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Attachment</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this attachment? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmDeleteAttachment" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -125,6 +142,16 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import AttachmentCard from './AttachmentCard.vue'
|
||||
import { taskService, type TaskAttachment } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
@@ -148,6 +175,8 @@ const filterType = ref('all')
|
||||
const viewerOpen = ref(false)
|
||||
const selectedAttachment = ref<TaskAttachment | null>(null)
|
||||
const mediaBlobUrl = ref<string | null>(null)
|
||||
const showDeleteDialog = ref(false)
|
||||
const attachmentToDelete = ref<number | null>(null)
|
||||
|
||||
const attachmentTypes = [
|
||||
{ value: 'all', label: 'All' },
|
||||
@@ -197,23 +226,30 @@ async function handleFileSelect(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(attachmentId: number) {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) return
|
||||
function handleDelete(attachmentId: number) {
|
||||
attachmentToDelete.value = attachmentId
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteAttachment() {
|
||||
if (attachmentToDelete.value === null) return
|
||||
|
||||
try {
|
||||
await taskService.deleteTaskAttachment(props.taskId, attachmentId)
|
||||
await taskService.deleteTaskAttachment(props.taskId, attachmentToDelete.value)
|
||||
emit('attachmentsUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Attachment deleted successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting attachment:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete attachment',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
attachmentToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tabs List (Fixed) -->
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
@@ -315,6 +315,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
initialTab?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -134,6 +134,11 @@ const columns = createColumns({
|
||||
onBulkStatusChange: handleBulkStatusChange,
|
||||
onStatusUpdated: handleStatusUpdated,
|
||||
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
|
||||
// Both actions open the task detail panel (which already has a working Reassign flow).
|
||||
// row-click is a no-op in TaskBrowser.vue (single click is reserved for selection) - only
|
||||
// row-double-click actually opens the panel, so reuse that instead.
|
||||
onViewDetails: (task: Task) => emit('row-double-click', task),
|
||||
onReassign: (task: Task) => emit('row-double-click', task),
|
||||
})
|
||||
|
||||
// TanStack Table configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { h, ref } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { ArrowUpDown, Film, Package, ChevronDown } from 'lucide-vue-next'
|
||||
import { ArrowUpDown, Film, Package, ChevronDown, MoreHorizontal, Eye, UserCog } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
|
||||
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||
import { type Task } from '@/services/task'
|
||||
@@ -27,6 +33,8 @@ interface ColumnCallbacks {
|
||||
onBulkStatusChange?: (status: TaskStatus) => void
|
||||
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
onViewDetails?: (task: Task) => void
|
||||
onReassign?: (task: Task) => void
|
||||
}
|
||||
|
||||
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
|
||||
@@ -308,5 +316,84 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
|
||||
return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at')))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const task = row.original
|
||||
return h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onMouseDown: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
callbacks?.onViewDetails?.(task)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Eye, { class: 'h-4 w-4 mr-2' }),
|
||||
'View Details',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
callbacks?.onReassign?.(task)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(UserCog, { class: 'h-4 w-4 mr-2' }),
|
||||
'Reassign',
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ProjectSettings',
|
||||
component: () => import('@/views/ProjectSettingsView.vue'),
|
||||
meta: { tab: 'settings', tabLabel: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: 'technical-specs',
|
||||
name: 'ProjectTechnicalSpecs',
|
||||
component: () => import('@/views/ProjectTechnicalSpecsView.vue'),
|
||||
meta: { tab: 'technical-specs', tabLabel: 'Technical Specs' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -150,18 +150,8 @@ class AssetService {
|
||||
}
|
||||
|
||||
const url = `/assets/?${params}`
|
||||
console.log('AssetService - Fetching assets from:', url)
|
||||
console.log('AssetService - Project ID:', projectId)
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(url)
|
||||
console.log('AssetService - Assets response status:', response.status)
|
||||
console.log('AssetService - Assets response data:', response.data)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('AssetService - Error fetching assets:', error)
|
||||
throw error
|
||||
}
|
||||
const response = await apiClient.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getAsset(assetId: number): Promise<Asset> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiClient } from './api'
|
||||
|
||||
export interface DeveloperStats {
|
||||
total_projects: number
|
||||
total_tasks: number
|
||||
total_submissions: number
|
||||
api_usage_count: number
|
||||
}
|
||||
|
||||
export const developerService = {
|
||||
async getStats(): Promise<DeveloperStats> {
|
||||
const response = await apiClient.get('/developer/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiClient } from './api'
|
||||
|
||||
export interface PendingReviewSubmission {
|
||||
id: number
|
||||
task_id: number
|
||||
user_id: number
|
||||
file_name: string
|
||||
version_number: number
|
||||
submitted_at: string
|
||||
user_first_name: string
|
||||
user_last_name: string
|
||||
}
|
||||
|
||||
export const reviewService = {
|
||||
async getPendingReviews(projectId?: number): Promise<PendingReviewSubmission[]> {
|
||||
const params = projectId ? { project_id: projectId } : {}
|
||||
const response = await apiClient.get('/reviews/pending', { params })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -86,17 +86,8 @@ export const userService = {
|
||||
is_approved?: boolean
|
||||
is_admin?: boolean
|
||||
}): Promise<User> {
|
||||
console.log('userService.editUser called with:', { userId, userData })
|
||||
try {
|
||||
const response = await apiClient.put(`/users/${userId}`, userData)
|
||||
return normalizeUser(response.data)
|
||||
} catch (error: any) {
|
||||
console.error('editUser API error:', error.response?.data)
|
||||
if (error.response?.data?.detail && Array.isArray(error.response.data.detail)) {
|
||||
console.error('Validation errors:', JSON.stringify(error.response.data.detail, null, 2))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const response = await apiClient.put(`/users/${userId}`, userData)
|
||||
return normalizeUser(response.data)
|
||||
},
|
||||
|
||||
async resetUserPassword(userId: number, newPassword: string): Promise<{ message: string; user_id: number }> {
|
||||
|
||||
@@ -295,8 +295,8 @@
|
||||
<div v-for="submission in pendingReviews" :key="submission.id" class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div class="h-2 w-2 rounded-full bg-orange-500"></div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">{{ submission.task_name }}</p>
|
||||
<p class="text-sm text-muted-foreground">by {{ submission.artist_name }}</p>
|
||||
<p class="font-medium">{{ submission.file_name }}</p>
|
||||
<p class="text-sm text-muted-foreground">by {{ submission.user_first_name }} {{ submission.user_last_name }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">{{ formatSubmissionTime(submission.submitted_at) }}</div>
|
||||
</div>
|
||||
@@ -341,10 +341,10 @@
|
||||
<div v-for="activity in systemActivity" :key="activity.id" class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">{{ activity.action }}</p>
|
||||
<p class="font-medium">{{ formatActivityType(activity.type) }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ activity.description }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">{{ formatTime(activity.timestamp) }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ formatTime(activity.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,10 +400,6 @@
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Manage API Keys
|
||||
</Button>
|
||||
<Button variant="outline" class="justify-start" @click="navigateTo('/developer/docs')">
|
||||
<FileText class="h-4 w-4 mr-2" />
|
||||
API Documentation
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Admin Actions (when user has admin permission) -->
|
||||
@@ -428,43 +424,57 @@
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
CheckSquare, Clock, CheckCircle, FolderOpen, User, AlertTriangle, Users,
|
||||
TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield,
|
||||
FileText
|
||||
CheckSquare, Clock, CheckCircle, FolderOpen, User, AlertTriangle, Users,
|
||||
TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { userService } from '@/services/user'
|
||||
import { apiKeyService } from '@/services/apiKey'
|
||||
import { developerService } from '@/services/developer'
|
||||
import { reviewService, type PendingReviewSubmission } from '@/services/review'
|
||||
import { getRecentActivities } from '@/services/activity'
|
||||
import type { Activity as ActivityRecord } from '@/types/activity'
|
||||
import { ActivityType } from '@/types/activity'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const tasksStore = useTasksStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const userRole = computed(() => authStore.userRole)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
|
||||
// Dashboard stats (mock data for now - would be fetched from API)
|
||||
const dashboardStats = ref({
|
||||
// Artist stats
|
||||
// TODO(backend): "my tasks/projects across all projects" has no dedicated endpoint yet
|
||||
activeTasks: 0,
|
||||
pendingReviews: 0,
|
||||
completedTasks: 0,
|
||||
myProjects: 0,
|
||||
|
||||
|
||||
// Coordinator stats
|
||||
totalProjects: 0,
|
||||
overdueTasks: 0,
|
||||
// TODO(backend): no endpoint for role-filtered active-artist counts
|
||||
activeArtists: 0,
|
||||
// TODO(backend): Project has no completion_rate field
|
||||
completionRate: 0,
|
||||
|
||||
|
||||
// Director stats
|
||||
// TODO(backend): no "approved/retakes today" aggregate endpoint
|
||||
approvedToday: 0,
|
||||
retakesRequested: 0,
|
||||
|
||||
|
||||
// Developer stats
|
||||
apiKeys: 0,
|
||||
// TODO(backend): /developer/stats' api_usage_count is all-time, not "today"
|
||||
apiCallsToday: 0,
|
||||
totalTasks: 0,
|
||||
|
||||
|
||||
// Admin stats
|
||||
totalUsers: 0,
|
||||
pendingApprovals: 0,
|
||||
@@ -473,23 +483,12 @@ const dashboardStats = ref({
|
||||
|
||||
// Role-specific data
|
||||
const recentTasks = ref([])
|
||||
// TODO(backend): project completion_rate isn't available yet, so this list is left empty for now
|
||||
const projectOverview = ref([])
|
||||
const pendingReviews = ref([])
|
||||
const pendingReviews = ref<PendingReviewSubmission[]>([])
|
||||
// TODO(backend): needs a frontend wrapper for GET /developer/api-usage plus per-day filtering
|
||||
const apiActivity = ref([])
|
||||
const systemActivity = ref([
|
||||
{
|
||||
id: 1,
|
||||
action: 'User Registration',
|
||||
description: 'New user registered and awaiting approval',
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
action: 'Project Created',
|
||||
description: 'New project "Animation Series" created',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString()
|
||||
}
|
||||
])
|
||||
const systemActivity = ref<ActivityRecord[]>([])
|
||||
|
||||
// Methods
|
||||
const formatRole = (role?: string) => {
|
||||
@@ -535,6 +534,13 @@ const formatTime = (timestamp: string) => {
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
const formatActivityType = (type: ActivityType) => {
|
||||
return type
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const getTaskStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not_started': return 'bg-gray-500'
|
||||
@@ -562,26 +568,59 @@ const navigateTo = (path: string) => {
|
||||
}
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
// Mock data loading - in real implementation, this would fetch from API based on user role
|
||||
try {
|
||||
// Load role-specific stats
|
||||
dashboardStats.value = {
|
||||
activeTasks: 5,
|
||||
pendingReviews: 2,
|
||||
completedTasks: 28,
|
||||
myProjects: 3,
|
||||
totalProjects: 8,
|
||||
overdueTasks: 3,
|
||||
activeArtists: 12,
|
||||
completionRate: 75,
|
||||
approvedToday: 8,
|
||||
retakesRequested: 2,
|
||||
apiKeys: 3,
|
||||
apiCallsToday: 156,
|
||||
totalTasks: 245,
|
||||
totalUsers: 25,
|
||||
pendingApprovals: 2,
|
||||
activeProjects: 5
|
||||
const needsProjects = userRole.value === 'coordinator' || userRole.value === 'director' || isAdmin.value
|
||||
const tasks: Promise<unknown>[] = []
|
||||
|
||||
if (needsProjects) {
|
||||
tasks.push(projectsStore.fetchProjects())
|
||||
}
|
||||
if (userRole.value === 'coordinator') {
|
||||
tasks.push(tasksStore.fetchTasks())
|
||||
}
|
||||
if (userRole.value === 'director') {
|
||||
tasks.push(
|
||||
reviewService.getPendingReviews().then(submissions => {
|
||||
pendingReviews.value = submissions
|
||||
})
|
||||
)
|
||||
}
|
||||
if (userRole.value === 'developer') {
|
||||
tasks.push(
|
||||
apiKeyService.getAPIKeys().then(keys => {
|
||||
dashboardStats.value.apiKeys = keys.length
|
||||
}),
|
||||
developerService.getStats().then(stats => {
|
||||
dashboardStats.value.totalProjects = stats.total_projects
|
||||
dashboardStats.value.totalTasks = stats.total_tasks
|
||||
})
|
||||
)
|
||||
}
|
||||
if (isAdmin.value) {
|
||||
tasks.push(
|
||||
userService.getUsers().then(users => {
|
||||
dashboardStats.value.totalUsers = users.length
|
||||
}),
|
||||
userService.getPendingUsers().then(users => {
|
||||
dashboardStats.value.pendingApprovals = users.length
|
||||
}),
|
||||
getRecentActivities().then(activities => {
|
||||
systemActivity.value = activities
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
|
||||
if (needsProjects) {
|
||||
dashboardStats.value.totalProjects = projectsStore.projects.length
|
||||
dashboardStats.value.activeProjects = projectsStore.projectsInProgress.length
|
||||
}
|
||||
if (userRole.value === 'coordinator') {
|
||||
dashboardStats.value.overdueTasks = tasksStore.overdueTasks.length
|
||||
}
|
||||
if (userRole.value === 'director') {
|
||||
dashboardStats.value.pendingReviews = pendingReviews.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Global Settings</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Manage system-wide configuration settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalSettingsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalSettingsPanel from '@/components/settings/GlobalSettingsPanel.vue'
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold text-foreground mb-4">
|
||||
VFX Project Management System
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
Welcome to the VFX Project Management System. This application will help you manage
|
||||
animation and VFX production workflows.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Get Started
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Home view component
|
||||
</script>
|
||||
@@ -147,8 +147,9 @@ const manageTechnicalSpecs = () => {
|
||||
}
|
||||
|
||||
const manageMembers = () => {
|
||||
// TODO: Implement member management
|
||||
console.log('Manage members')
|
||||
if (projectId.value) {
|
||||
router.push(`/projects/${projectId.value}/settings`)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
|
||||
@@ -346,9 +346,9 @@
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ProjectMembersManager
|
||||
<ProjectMemberManagement
|
||||
v-if="selectedProject && showMembersDialog"
|
||||
:project="selectedProject"
|
||||
:project-id="selectedProject.id"
|
||||
@close="showMembersDialog = false"
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -407,7 +407,7 @@ import {
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProjectMembersManager from '@/components/project/ProjectMembersManager.vue'
|
||||
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
|
||||
import type { Project } from '@/stores/projects'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
|
||||
@@ -257,15 +257,11 @@ const handleEditUser = (user: User) => {
|
||||
const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
|
||||
try {
|
||||
isEditingUser.value = true
|
||||
console.log('Editing user with data:', data)
|
||||
await userStore.editUser(userId, data)
|
||||
showEditDialog.value = false
|
||||
showSuccessMessage('User updated successfully')
|
||||
await refreshData()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to edit user:', err)
|
||||
console.error('Error response:', err.response?.data)
|
||||
|
||||
// Handle FastAPI validation errors (422)
|
||||
let errorMessage = 'Failed to update user'
|
||||
if (err.response?.data?.detail) {
|
||||
@@ -276,7 +272,6 @@ const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
|
||||
return `${field}: ${e.msg}`
|
||||
}).join(', ')
|
||||
errorMessage = errors
|
||||
console.error('Validation errors:', err.response.data.detail)
|
||||
} else {
|
||||
errorMessage = err.response.data.detail
|
||||
}
|
||||
|
||||
@@ -915,26 +915,18 @@ const loadDeletedItems = async () => {
|
||||
|
||||
const projectId = selectedProjectId.value && selectedProjectId.value !== 'all' ? parseInt(selectedProjectId.value) : undefined
|
||||
|
||||
console.log('Loading recovery data with projectId:', projectId)
|
||||
|
||||
const [shots, assets] = await Promise.all([
|
||||
recoveryService.getDeletedShots(projectId),
|
||||
recoveryService.getDeletedAssets(projectId)
|
||||
])
|
||||
|
||||
console.log('Loaded shots for recovery:', shots.length)
|
||||
console.log('Loaded assets for recovery:', assets.length)
|
||||
console.log('Shots data:', shots)
|
||||
|
||||
deletedShots.value = shots
|
||||
deletedAssets.value = assets
|
||||
|
||||
|
||||
// Clear selections when data changes
|
||||
selectedItems.value = []
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to load recovery data'
|
||||
console.error('Failed to load recovery data:', err)
|
||||
console.error('Error details:', err.response)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -43,16 +43,6 @@
|
||||
Login
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full" type="button" @click="handleGoogleLogin">
|
||||
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</Button>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -103,8 +93,4 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// TODO: Implement Google OAuth login
|
||||
console.log('Google login not implemented yet')
|
||||
}
|
||||
</script>
|
||||
@@ -73,16 +73,6 @@
|
||||
Create account
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full" type="button" @click="handleGoogleSignup">
|
||||
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign up with Google
|
||||
</Button>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -217,8 +207,4 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignup = () => {
|
||||
// TODO: Implement Google OAuth signup
|
||||
console.log('Google signup not implemented yet')
|
||||
}
|
||||
</script>
|
||||
@@ -11,8 +11,6 @@
|
||||
:icon="Key"
|
||||
title="No API keys"
|
||||
description="Create API keys to integrate external applications with the VFX system."
|
||||
action-text="Create API Key"
|
||||
@action="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,19 +37,8 @@ const assetsStore = useAssetsStore()
|
||||
// Computed properties
|
||||
const projectId = computed(() => {
|
||||
const id = route.params.projectId
|
||||
const parsedId = typeof id === 'string' ? parseInt(id) : Array.isArray(id) ? parseInt(id[0]) : 0
|
||||
console.log('ProjectAssetsView - Route params:', route.params)
|
||||
console.log('ProjectAssetsView - Raw project ID:', id)
|
||||
console.log('ProjectAssetsView - Parsed project ID:', parsedId)
|
||||
return parsedId
|
||||
return typeof id === 'string' ? parseInt(id) : Array.isArray(id) ? parseInt(id[0]) : 0
|
||||
})
|
||||
|
||||
const totalAssets = computed(() => {
|
||||
console.log('ProjectAssetsView - Total assets:', assetsStore.assets.length)
|
||||
return assetsStore.assets.length
|
||||
})
|
||||
|
||||
// Debug logging on mount
|
||||
console.log('ProjectAssetsView - Component mounted')
|
||||
console.log('ProjectAssetsView - Route params:', route.params)
|
||||
const totalAssets = computed(() => assetsStore.assets.length)
|
||||
</script>
|
||||
@@ -105,29 +105,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<Card class="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<Activity class="h-8 w-8 mx-auto mb-2" />
|
||||
<p>Activity feed coming soon</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="mt-6" v-if="project">
|
||||
<ActivityFeed :project-id="project.id" title="Recent Activity" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Camera, Package, Users, Activity } from 'lucide-vue-next'
|
||||
import { Camera, Package, Users } from 'lucide-vue-next'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import TechnicalSpecsSummary from '@/components/project/TechnicalSpecsSummary.vue'
|
||||
import ActivityFeed from '@/components/activity/ActivityFeed.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# LinkDesk Frontend Report
|
||||
|
||||
**Scope:** `frontend/src/` only (Vue 3 + TypeScript + Pinia + Vue Router + shadcn-vue/reka-ui + TanStack Table). No backend changes proposed. Compiled from a full-codebase audit (grep sweeps + file reads across stores, views, components, router, services).
|
||||
|
||||
**Purpose:** give a concrete, prioritized punch list to plan the next phase of frontend development — what's broken or fake, what's inconsistent, and what's architecturally risky.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The app is functionally broad but shows clear signs of iterative, deadline-driven development without cleanup passes: **the dashboard's data is entirely fabricated**, **one advertised route 404s**, **six asset detail-panel actions are no-ops**, and there are **at least eight dead/orphaned component files** still sitting in the tree. Beyond that, the same UI concept (column-visibility toggle, delete confirmation, toolbar, checkbox) is often implemented 2-4 different ways across shot/asset/task domains, and there is **no test suite and no lint config** guarding any of it.
|
||||
|
||||
None of this is catastrophic — the core shot/asset/task browsing flows work — but it means the next phase of work should prioritize **truth-in-UI fixes and dead-code removal first** (cheap, high trust impact), **then control unification** (moderate effort, big maintainability payoff), **then architecture/tooling investment** (testing, shared composables, performance) before adding more features on top of the current duplication.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unfinished / Incomplete Functionality
|
||||
|
||||
### 1.1 Misleading or broken right now (fix first)
|
||||
|
||||
| Issue | Where | Impact |
|
||||
|---|---|---|
|
||||
| **Dashboard shows fabricated data** | `views/DashboardView.vue:445-472, 564-589` | `activeTasks`, `pendingReviews`, `completedTasks`, `systemActivity`, etc. are hardcoded constants, not fetched — every user sees the same fake numbers on the app's landing page regardless of role or real state. |
|
||||
| **"Manage Technical Specs" 404s** | Called from `views/ProjectDetailView.vue:145`, `views/EpisodesView.vue:309`, `views/ProjectsView.vue:547` → `router.push('/projects/:id/technical-specs')` | `router/index.ts` has no route registered for this path, even though `views/ProjectTechnicalSpecsView.vue` and its backing `services/project.ts:161-171` calls are fully built. Three navigation call sites currently dead-end at `NotFoundView`. |
|
||||
| **Google OAuth buttons do nothing** | `views/auth/LoginView.vue:106-109`, `views/auth/RegisterView.vue:220-223` | Both `// TODO: Implement Google OAuth` + `console.log(...)`. Either finish it or remove the button so users don't try a dead flow. |
|
||||
| **Asset detail-panel actions are all stubs** | `components/asset/AssetBrowser.vue:571-574, 680-703` | `viewAssetTasks`, `handleCreateTask`, `handleSelectTask`, `handleCreateNote`, `handleUploadReference`, `handlePublishVersion` — six handlers, all `console.log` only. The entire "act on an asset" surface is non-functional. |
|
||||
| **Shot detail panel has partial equivalents** | `components/shot/ShotBrowser.vue:848-856` | `handleCreateTask`/`handleSelectTask` are also `// TODO: Navigate to task creation...` stubs — smaller gap than assets but same pattern. |
|
||||
| **"Manage Members" does nothing** | `views/ProjectDetailView.vue:149-151` | `console.log('Manage members')` — while two separate real member-management components already exist (see 1.3), this entry point isn't wired to either. |
|
||||
| **Task-status usage counts are fake** | `components/settings/CustomTaskStatusManager.vue:275-295` | `// TODO: Implement actual task count fetching` — always shows 0 regardless of real usage, which could mislead an admin into deleting a status that's actually in use. |
|
||||
| **Episode progress bars are a lookup table, not real data** | `components/episode/EpisodeCard.vue:117-128` and `components/episode/EpisodeList.vue:267-278` (duplicated logic) | `switch(status) { case 'in_progress': return 45; ... }` — fixed numbers per status, not derived from actual shot completion. |
|
||||
| **Native browser `confirm()` used for real deletes** | `components/task/NoteItem.vue:178`, `components/task/TaskAttachments.vue:201` | Unstyled OS dialog, breaks the app's own "type to confirm" / `AlertDialog` conventions used everywhere else. |
|
||||
|
||||
### 1.2 Stubbed/placeholder pages
|
||||
|
||||
| Page | File | State |
|
||||
|---|---|---|
|
||||
| Reviews | `views/ReviewsView.vue` (22 lines) | Empty state only ("No pending reviews"), no workflow at all. |
|
||||
| Developer: API Keys / Projects / Tasks / Analytics | `views/developer/*.vue` (22-24 lines each) | All four are `EmptyState` shells with no data fetching. `APIKeysView.vue` additionally has a dead `@action="() => {}"` on its only button. |
|
||||
| Project Overview activity feed | `views/project/ProjectOverviewView.vue:107-118` | Static "Activity feed coming soon" text — even though a fully-built `ActivityFeed.vue` component exists and is simply never wired in (see 1.3). |
|
||||
| User menu items | `components/layout/UserMenu.vue:68, 84` | Link to `/settings/preferences` and `/help`, neither of which is a registered route — both 404. |
|
||||
| User menu "Notifications"/"Keyboard Shortcuts" | `components/layout/UserMenu.vue:201-219` | Toggle doesn't persist anything; keyboard-shortcuts item falls back to a browser `alert()`. |
|
||||
|
||||
### 1.3 Dead / orphaned component files (safe to delete or finish)
|
||||
|
||||
These are fully-written components with **zero references anywhere else in `src`** (confirmed via content grep, not just filename):
|
||||
|
||||
- `components/shot/ShotColumnVisibilityControl.vue` — superseded by `SidebarColumnSwitch.vue`.
|
||||
- `components/asset/ColumnVisibilityControl.vue` — superseded by the inline toolbar Popover+Command control.
|
||||
- `components/project/ShotsTable.vue` (280 lines) — an earlier, abandoned shot-browser implementation whose CRUD handlers are all TODO stubs, fully superseded by `ShotBrowser.vue`.
|
||||
- `components/activity/ActivityFeed.vue` and `components/activity/TaskActivityTimeline.vue` — both fully built (pagination, service calls, formatting) but never imported anywhere; `ProjectOverviewView.vue` shows a "coming soon" placeholder instead of using either (see 1.2).
|
||||
- `components/examples/FileUploadExample.vue` — demo/example component shipped in production `src`.
|
||||
- `views/GlobalSettingsView.vue` — byte-for-byte duplicate of `views/SettingsView.vue`, not routed anywhere.
|
||||
- `views/HomeView.vue` — orphaned landing page scaffold, superseded by `DashboardView.vue`, not routed anywhere.
|
||||
- `components/asset/AssetDeleteConfirmDialog.vue` — fully built, never imported; `AssetBrowser.vue` uses a plain generic `AlertDialog` instead (see §2.3).
|
||||
|
||||
### 1.4 Feature parity gaps (asset lags shot)
|
||||
|
||||
- **User assignment**: `components/shot/EditableTaskStatus.vue` (413 lines) has a full assignee popover; `components/asset/EditableTaskStatus.vue` (197 lines) has none — assets can't be assigned to a user from the table.
|
||||
- **Column locking**: `ShotTableToolbar.vue` has a "lock first columns" toggle; `AssetTableToolbar.vue` has no equivalent.
|
||||
- **Row actions menu**: shot/asset `columns.ts` both have a `DropdownMenu` row-actions menu; `components/task/columns.ts` doesn't — tasks have no per-row "…" actions.
|
||||
|
||||
### 1.5 Debug artifacts left in shipped code
|
||||
|
||||
Heavy `console.log`/`console.error` tracing left in real (non-debug) code paths — worth a cleanup pass regardless of the features above:
|
||||
`views/UsersView.vue:257-267`, `components/settings/CustomTaskTypeManager.vue:430-483`, `views/project/ProjectAssetsView.vue:38-54`, `components/project/ProjectTabs.vue:104-147`, `services/asset.ts:153-159`, `services/user.ts:89`, `views/admin/DeletedItemsManagementView.vue:918-937`, plus a stray commented-out debug block in `components/asset/EditableTaskStatus.vue:16-19`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Unified Controls (Consistency)
|
||||
|
||||
The recurring theme: **the same interaction gets reinvented per domain (shot/asset/task)** instead of shared once. Below, each pattern lists what exists today and which version should become the standard.
|
||||
|
||||
### 2.1 Column visibility — 4 competing implementations, 2 of them dead
|
||||
|
||||
| Implementation | Status | Notes |
|
||||
|---|---|---|
|
||||
| `components/ui/sidebar/SidebarColumnSwitch.vue` | **Live** (used in `AppSidebar.vue`) | Reads from `useColumnVisibilityStore` directly; hand-rolled checkbox div in expanded mode, real `DropdownMenuCheckboxItem` in collapsed mode (this repo's newest sidebar work). |
|
||||
| Inline Popover+Command block in `ShotTableToolbar.vue` / `AssetTableToolbar.vue` / `TaskTableToolbar.vue` | **Live** | Same 10-line hand-rolled checkbox-div markup copy-pasted 3x; takes `columnVisibility` as a prop/emit pair instead of the store. |
|
||||
| `components/shot/ShotColumnVisibilityControl.vue` | **Dead** | `Select` + native `<input type="checkbox">` nested inside a `SelectItem` — also has a real double-toggle bug (checkbox `@change` and parent `@click` both fire, flipping state twice) and an ARIA violation (interactive control inside `role="option"`). |
|
||||
| `components/asset/ColumnVisibilityControl.vue` | **Dead** | Same pattern as above. |
|
||||
|
||||
**Recommendation:** delete both dead files; extract one `ColumnToggleList` component (built on `DropdownMenuCheckboxItem`, not the div+Check hack) and have the sidebar and all three toolbars consume it against the single `useColumnVisibilityStore`.
|
||||
|
||||
### 2.2 Toolbars — near-duplicate files with drifting features
|
||||
|
||||
`ShotTableToolbar.vue`, `AssetTableToolbar.vue`, and `TaskTableToolbar.vue` share the same skeleton (debounced 300ms search reimplemented in each file, hidden-columns-count logic, detail-panel toggle) but have diverged:
|
||||
- Shot has a 3-way grid/list/table view toggle + column-lock toggle + bulk-create; Asset has only grid/list + a thumbnail show/hide toggle; neither toggle group is shared, each is a hand-built segmented control.
|
||||
- Task has its own third segmented-control implementation (all/shots/assets context filter) plus a bespoke bulk-actions menu (`TaskBulkActionsMenu.vue`) built from `Popover` + ad hoc `<button>`s rather than `DropdownMenuItem`.
|
||||
- All three end with an icon-only "create" button styled as `size="sm" class="h-8 w-8 p-0"` reinventing the existing `size="icon-sm"` button variant, and — unlike sibling icon buttons in the same toolbar — without a `title` tooltip, making the single most important CTA the least discoverable control in the toolbar.
|
||||
|
||||
**Recommendation:** extract a shared `EntityTableToolbar` (search, column-visibility trigger, detail-panel toggle, clear-filters) parameterized by column/filter definitions; extract one `SegmentedToggle` component for the three hand-built view-mode switches; standardize primary create buttons on either icon+label or icon-only+tooltip, not a mix.
|
||||
|
||||
### 2.3 Delete confirmation — inconsistent safety, not just style
|
||||
|
||||
- `components/shot/ShotDeleteConfirmDialog.vue`: rich `Dialog` + `Alert`, fetches deletion-impact summary (task/submission/attachment counts, affected users), requires typing the shot name to confirm. **This is the most mature pattern and should be the standard for any cascading delete.**
|
||||
- `components/asset/AssetDeleteConfirmDialog.vue`: a built near-clone of the above, but re-implements alerts as raw `<div>`s with manual color classes instead of the shared `Alert` component — **and it's dead code**, never imported.
|
||||
- `AssetBrowser.vue`'s actual delete flow uses a bare generic `AlertDialog` with a one-line message — no impact summary, no affected-users list, no type-to-confirm — meaning **asset deletion is materially less safe than shot deletion** today.
|
||||
- Task-related deletes (`NoteItem.vue`, `TaskAttachments.vue`) bypass the Vue dialog system entirely via native `confirm()` (see 1.1).
|
||||
|
||||
**Recommendation:** wire `AssetDeleteConfirmDialog.vue` into `AssetBrowser.vue` (fixing its `Alert` divergence first), or delete it and consciously accept the lighter `AlertDialog` for assets; replace the two native `confirm()` calls with `AlertDialog`.
|
||||
|
||||
### 2.4 Hand-rolled checkbox vs. real `Checkbox` component
|
||||
|
||||
A real, accessible `Checkbox` component already exists at `components/ui/checkbox/Checkbox.vue` (Reka UI-backed, same visual spec) and is correctly used for table row-selection. But every Popover/Command filter and column-toggle list instead hand-rolls the same look as a plain `<div>` + conditional class + `Check` icon — no keyboard support, no ARIA state. Affected files: `SidebarColumnSwitch.vue`, `ShotTableToolbar.vue`, `AssetTableToolbar.vue`, `TaskTableToolbar.vue`, `ShotTaskStatusFilter.vue`, `asset/TaskStatusFilter.vue`. Low-risk, high-value fix since the visual tokens already match byte-for-byte.
|
||||
|
||||
### 2.5 Button sizing
|
||||
|
||||
`components/ui/button/index.ts` already defines `icon`, `icon-sm`, `icon-lg` variants, yet toolbar icon buttons across shot/asset/task/`ShotBrowser.vue`/`ProjectMemberManagement.vue` write `size="sm" class="h-8 w-8 p-0"` instead of `size="icon-sm"`. Separately, primary "create" CTAs are inconsistent in kind: `ProjectsView.vue`/`EpisodeList.vue` use labeled default buttons, while the equivalent shot/asset create buttons are icon-only with no tooltip — the more important action is the less discoverable one.
|
||||
|
||||
### 2.6 Detail panels — good state layer, duplicated shell
|
||||
|
||||
`composables/useDetailPanel.ts` is a well-designed, correctly shared composable (auto-enable, keyboard toggle, mobile sheet, persistence) used consistently by all three browsers — this part is **already unified and worth keeping as the model** for other unification work. But the actual panel markup (`ShotDetailPanel.vue` 521 lines, `AssetDetailPanel.vue` 481 lines, `TaskDetailPanel.vue` 535 lines) each independently re-implement the loading spinner, error block, header/close button, and `Tabs` scaffold — Task's panel notably has no error state at all where Shot/Asset do.
|
||||
|
||||
**Recommendation:** extract a `DetailPanelShell.vue` (header, close button, loading/error states, slide-in transition wrapper, Tabs scaffold) that the three domain panels plug tab content into.
|
||||
|
||||
### 2.7 Dropdown/menu primitive choice
|
||||
|
||||
Filter dropdowns are consistently `Popover` + `Command` (good). But row-actions and bulk-actions menus are not: shot/asset `columns.ts` use real `DropdownMenu`/`DropdownMenuItem` for row actions; `task/columns.ts` has no row-actions menu at all; `TaskBulkActionsMenu.vue` builds its own context menu from `Popover` + ad hoc `<button>` elements instead of `DropdownMenuItem`. Standardize row/bulk actions on `DropdownMenu`.
|
||||
|
||||
### 2.8 Project member management — two parallel components
|
||||
|
||||
`components/project/ProjectMembersManager.vue` (322 lines, used from `ProjectsView.vue`) and `components/project/ProjectMemberManagement.vue` (414 lines, used from `ProjectSettingsView.vue`) both implement "manage project members" independently, and neither is wired to the still-stubbed `ProjectDetailView.vue` "Manage Members" action (§1.1). Consolidate to one component with one entry point.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture & Smarter Design
|
||||
|
||||
### 3.1 State management (`stores/*.ts`)
|
||||
|
||||
No store is individually huge (largest is `assets.ts` at 380 lines), but **every store re-implements the same `isLoading`/`error`/try-catch-finally boilerplate** with no shared `useAsyncAction`-style composable. Caching strategy is also inconsistent: most stores (`assets`, `tasks`, `projects`, `user`, `episodes`) have none — every view re-fetches on mount — while `taskStatuses.ts` invents its own bespoke `Map` cache with a 5-minute TTL and, notably, de-duplicates in-flight requests via a **100ms `setInterval` poll loop up to a 10s timeout** rather than memoizing the in-flight promise — a real correctness/perf smell and a one-off pattern not reused elsewhere.
|
||||
|
||||
`assets.ts` also duplicates its optimistic-update/rollback logic almost verbatim between the single- and bulk-update task-status actions (lines 156-234 vs 236-356) — a good candidate to unify into one parameterized function.
|
||||
|
||||
`projects.ts` stores Vue/lucide icon components directly in reactive state without `markRaw` (`assignProjectIcon`), even though `ShotBrowser.vue` correctly uses `markRaw` for its column defs elsewhere — inconsistent application of a pattern the team already knows. `ProjectsView.vue` then puts a `deep: true` watch on this same array, compounding the cost.
|
||||
|
||||
### 3.2 Type safety
|
||||
|
||||
`types/` holds only 150 lines across 3 files (`auth.ts`, `notification.ts`, `activity.ts`); nearly all domain types (`Shot`, `Asset`, `Task`, `Project`, etc.) live ad hoc inside `services/*.ts` files instead, blurring the service/type boundary. `catch (err: any)` appears **104 times across 27 files** with no shared typed `ApiError` helper — every call site re-derives `err.response?.data?.detail` by hand. `as any` casts (13 occurrences) mostly work around the `TaskStatus`/task-type union types not supporting dynamic field access. `tsconfig.json` has `strict: true` but `noUnusedLocals: false` (explicitly turned off), and **there is no ESLint config anywhere in the repo** — `vue-tsc --noEmit` is the only automated quality gate.
|
||||
|
||||
### 3.3 Performance
|
||||
|
||||
- **No table virtualization anywhere** — no `@tanstack/vue-virtual` dependency, no pagination row model; `ShotsDataTable.vue`/`AssetsDataTable.vue`/`TasksDataTable.vue` render every row as real DOM. This is the top scaling risk for productions with thousands of shots.
|
||||
- **N+1 per-cell API calls**: `EditableTaskStatus.vue` is mounted once per (row × task-type column); its `onMounted` calls `getProjectMembers()` with no shared cache. A 50-row × 5-column table view fires ~250 redundant identical member-list requests. (Status data itself is correctly cached via `taskStatusesStore` — only the member list isn't.)
|
||||
- `deep: true` watchers appear in 12 places, mostly harmless on small flat objects, but `ProjectsView.vue:770`'s deep watch on the full (non-`markRaw`'d) projects array is a real concern.
|
||||
- Client-side filtering of full shot/asset arrays will get slower as data grows, even though the backend already partially supports server-side filtering (`taskStatusFilter`, `episodeId` params).
|
||||
|
||||
### 3.4 Component size
|
||||
|
||||
Largest files: `views/project/ShotDetailView.vue` (1658 lines), `views/admin/DeletedItemsManagementView.vue` (1279 lines), `ShotBrowser.vue` (949), `ProjectsView.vue` (801), `AssetBrowser.vue` (800), `ProfileView.vue` (774). The Browser/DetailView/DetailPanel triad repeats a similar shape across shot/asset/task domains (`ShotBrowser.vue` and `AssetBrowser.vue` share roughly 70% structure) — a shared "entity browser" composable/component would shrink both duplication and file size together.
|
||||
|
||||
### 3.5 Error handling / loading / empty states
|
||||
|
||||
A `Skeleton` component exists but is used in only 5 files, versus 62 files using ad hoc spinner divs — no consistent loading convention. Empty-state messaging is hand-rolled in 31 files instead of a shared `EmptyState` component. Most importantly: only 38 of 284 `.vue` files use `useToast`, versus 60 files with 130 total `console.error` calls — secondary data loads (episodes, task types, task statuses, project context in `ShotBrowser.vue`, notification stats, auth-init failures in the router guard) fail silently with no user-facing feedback.
|
||||
|
||||
### 3.6 Testing
|
||||
|
||||
**No test infrastructure exists at all** — no `*.test.ts`/`*.spec.ts` files, no Vitest/Jest config, no test dependencies in `package.json`, no `test` script. All the optimistic-update/rollback logic in `assets.ts` and the auth token lifecycle in `auth.ts` are verified only by manual QA. This is the single biggest structural gap for a production-management tool.
|
||||
|
||||
### 3.7 Accessibility
|
||||
|
||||
reka-ui-based primitives (Checkbox, Select, dialogs) provide correct ARIA/keyboard behavior out of the box, but custom composition on top of them introduces gaps: the checkbox-inside-`SelectItem` anti-pattern noted in §2.1/2.4, hand-rolled clickable `<div>`s with no keyboard support (`SubmissionCard.vue`, `EditableTaskStatus.vue`), and only 7 of 256 component files reference any `aria-*`/`tabindex` attribute. Image `alt` text is handled reasonably well where sampled.
|
||||
|
||||
### 3.8 Routing/guards
|
||||
|
||||
`router/index.ts`'s single `beforeEach` reading `meta.requiresAuth`/`roles`/`adminPermission` is a genuinely good, centralized pattern — no per-view guard duplication. The gap is that role/permission checks are *also* duplicated ad hoc across ~20 components (to hide nav items/buttons) with no shared `usePermission()`/`can()` composable, so role-rule changes require touching both the router meta and scattered component checks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Development Roadmap
|
||||
|
||||
Ordered by "cheapest + most trust-restoring first," so each phase is shippable on its own.
|
||||
|
||||
### Phase 1 — Truth-in-UI & dead code (low effort, high trust impact)
|
||||
- Wire the dashboard to real data or clearly label it as a placeholder; same for episode progress bars and task-status usage counts.
|
||||
- Register the missing `/projects/:projectId/technical-specs` route.
|
||||
- Delete the 8 confirmed-dead files (§1.3), or finish wiring `ActivityFeed.vue` into `ProjectOverviewView.vue` since it's already built.
|
||||
- Replace the two native `confirm()` calls with `AlertDialog`.
|
||||
- Remove leftover debug `console.log`/`console.error` traces (§1.5).
|
||||
- Either finish or remove: Google OAuth buttons, "Manage Members" stub, `/developer/*` placeholder pages, dead `/help`/`/settings/preferences` menu links.
|
||||
|
||||
### Phase 2 — Close feature gaps
|
||||
- Bring asset-side up to shot-side parity: user assignment in `EditableTaskStatus`, column locking in the asset toolbar, row-actions menu for tasks.
|
||||
- Finish the six stubbed asset detail-panel actions (create task/note, select task, upload reference, publish version) or hide the affordances until built.
|
||||
- Consolidate `ProjectMembersManager.vue`/`ProjectMemberManagement.vue` into one component and wire it to the "Manage Members" entry point.
|
||||
|
||||
### Phase 3 — Unify controls
|
||||
- Extract `ColumnToggleList` (built on `DropdownMenuCheckboxItem`) and point the sidebar + all three toolbars at it.
|
||||
- Extract `EntityTableToolbar` + `SegmentedToggle` to de-duplicate shot/asset/task toolbars.
|
||||
- Extract `DetailPanelShell` for the shared header/loading/error/Tabs scaffold across the three detail panels.
|
||||
- Standardize delete confirmation on the shot pattern (`Dialog` + `Alert` + impact summary) for cascading deletes; fix or retire the asset variant.
|
||||
- Swap hand-rolled checkbox divs for the real `Checkbox`/`DropdownMenuCheckboxItem`; replace ad hoc `h-8 w-8 p-0` button classes with `size="icon-sm"`.
|
||||
|
||||
### Phase 4 — Architecture & tooling investment
|
||||
- Add a shared `useAsyncAction`-style composable to cut store boilerplate; replace `taskStatuses.ts`'s polling-based in-flight de-dup with promise memoization; extend a similar cache to `assets`/`tasks`/`projects`/`episodes` stores.
|
||||
- Add `markRaw` where components are stored in reactive state (`projects.ts`); audit `deep: true` watchers.
|
||||
- Fix the `EditableTaskStatus` N+1 member-fetch by hoisting to a shared cache/prop.
|
||||
- Introduce a shared `EmptyState` component and standardize on `Skeleton` for loading; adopt a consistent toast-on-error convention for secondary data loads.
|
||||
- Centralize a `usePermission()`/`can()` composable to consolidate role checks currently scattered across ~20 components.
|
||||
|
||||
### Phase 5 — Testing & quality gates
|
||||
- Add Vitest + Vue Test Utils, starting with the optimistic-update/rollback logic in `assets.ts` and the auth token lifecycle in `auth.ts` (highest-risk untested logic).
|
||||
- Add an ESLint config (flag `any`, unused locals) and re-enable `noUnusedLocals` in `tsconfig.json`.
|
||||
- Introduce a typed `ApiError` helper to replace the 104 `catch (err: any)` sites incrementally as touched.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Frontend Improvement Tasks
|
||||
|
||||
Derived from `frontend_report.md`. Checklist form for tracking progress — check items off as they land. Ordered by phase (do Phase 1 before Phase 2, etc.); within a phase, order doesn't matter much.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Truth-in-UI & dead code ✅ (done)
|
||||
|
||||
- [x] Wire `views/DashboardView.vue` stats to real API data where an endpoint exists (projects, active projects, overdue tasks, total users, pending approvals, API keys, developer stats, pending reviews, admin system activity). Added `services/developer.ts` and `services/review.ts` wrappers.
|
||||
- [ ] Replace fixed-lookup episode progress with real computed progress — `components/episode/EpisodeCard.vue:117-128` and `components/episode/EpisodeList.vue:267-278` — **deferred**: backend has no `completed_shots`/`progress` field yet (`backend/schemas/episode.py`, `backend/routers/episodes.py`)
|
||||
- [ ] Fix task-status usage counts always showing 0 — `components/settings/CustomTaskStatusManager.vue:275-295` — **deferred**: needs a new `GET /projects/{id}/task-statuses/counts` backend endpoint
|
||||
- [x] Register the missing route: `/projects/:projectId/technical-specs` in `router/index.ts`
|
||||
- [x] Delete confirmed-dead files (or finish wiring them in):
|
||||
- [x] `components/shot/ShotColumnVisibilityControl.vue` — deleted
|
||||
- [x] `components/asset/ColumnVisibilityControl.vue` — deleted
|
||||
- [x] `components/project/ShotsTable.vue` — deleted
|
||||
- [x] `components/examples/FileUploadExample.vue` — deleted
|
||||
- [x] `views/GlobalSettingsView.vue` — deleted
|
||||
- [x] `views/HomeView.vue` — deleted
|
||||
- [x] `components/asset/AssetDeleteConfirmDialog.vue` — fixed (raw divs → shared `Alert` component) and wired into `AssetBrowser.vue`, mirroring `ShotBrowser.vue`'s pattern
|
||||
- [x] `components/activity/ActivityFeed.vue` — wired into `ProjectOverviewView.vue` in place of "coming soon" text; `TaskActivityTimeline.vue` — deleted (no `taskId` available at that call site)
|
||||
- [x] Replace native `confirm()`/`alert()` with styled dialogs:
|
||||
- [x] `components/task/NoteItem.vue:178` → `AlertDialog`
|
||||
- [x] `components/task/TaskAttachments.vue:201` → `AlertDialog`
|
||||
- [x] `components/layout/UserMenu.vue` keyboard-shortcuts `alert()` → `Dialog`
|
||||
- [x] Remove leftover debug logging (all 8 files/line-ranges)
|
||||
- [x] Removed Google OAuth stub buttons (`LoginView.vue`, `RegisterView.vue`) — no OAuth backend exists, so the buttons were pure dead ends
|
||||
- [ ] `/developer/api-keys`, `/developer/projects`, `/developer/tasks`, `/developer/analytics`, `views/ReviewsView.vue` — **left as-is**: their empty states are honest ("No API keys" etc.), not misleading, so out of scope for a truth-in-UI pass. Only fixed the one dead action button on `APIKeysView.vue` (removed `action-text`/`@action` since it did nothing).
|
||||
- [x] Removed dead-end UserMenu items (`/settings/preferences`, `/help`, notifications toggle) and the `/developer/docs` dead link (`AppSidebar.vue`, `DashboardView.vue`) — no real destination existed for any of them
|
||||
|
||||
**Bonus fixes surfaced during verification (approved mid-implementation):**
|
||||
- Fixed two pre-existing backend bugs that 500'd once real data was wired up: `backend/routers/reviews.py` (`joinedload("reviewer")` string → `joinedload(Review.reviewer)`) and `backend/utils/activity.py` (wrong `models.submission` import path → `models.task`).
|
||||
- Fixed a pre-existing bug shared by both `ShotDeleteConfirmDialog.vue` and `AssetDeleteConfirmDialog.vue`: their `watch(() => props.open, ...)` lacked `{ immediate: true }`, so the impact-summary section never rendered since the dialog only mounts once already open. Deletion itself worked fine either way — this was purely cosmetic, but it was the whole point of the "impact summary" safety feature.
|
||||
|
||||
## Phase 2 — Close feature gaps (asset vs. shot parity) ✅ (done)
|
||||
|
||||
- [x] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` — ported from the shot version; wired through `asset/columns.ts` and `AssetBrowser.vue`
|
||||
- [x] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` — two-pane frozen-column layout ported into `AssetsDataTable.vue` (adapted for asset's page-scroll layout, no vertical scroll-sync needed unlike shot's bounded-height container)
|
||||
- [x] Add row-actions ("…") menu to `components/task/columns.ts` — "View Details" + "Reassign" (no Delete/Edit — no backend `deleteTask`/task-edit-form exists)
|
||||
- [x] Implement the asset detail-panel actions in `AssetBrowser.vue`/`AssetDetailPanel.vue`: create task (task-type picker), select task (opens `TaskDetailPanel` in-place), create note/upload reference/publish version (task picker → deep-links into `TaskDetailPanel`'s Notes/Attachments/Submissions tabs via new `initialTab` prop)
|
||||
- [x] Implement the same for `ShotBrowser.vue`/`ShotDetailPanel.vue` (also fixed the pre-existing dead "Add Note"/"Upload Reference" buttons there)
|
||||
- [x] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component (kept `ProjectMemberManagement.vue`, deleted the other) — combined remove-confirmation + approved-user filtering + real avatars from one with toast feedback + shared `Select`/`Dialog` UI-kit from the other
|
||||
- [x] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` — navigates to the project Settings "Team" tab (mirrors the existing `manageTechnicalSpecs` pattern) rather than a duplicate dialog
|
||||
|
||||
**Bugs found and fixed during verification (approved mid-implementation):**
|
||||
- `GET /assets/{id}` never returned `task_details` (schema didn't even declare the field) — unlike `GET /shots/{id}`, which already did. Broke the asset detail panel's task list and, transitively, every new create-task/note/reference/version feature. Fixed in `backend/schemas/asset.py` + `backend/routers/assets.py`.
|
||||
- `TaskBrowser.vue`'s `handleRowClick` is a no-op by design (single click reserved for selection) — the new row-actions menu's "View Details"/"Reassign" needed to emit `row-double-click` instead, which is what actually opens the panel.
|
||||
|
||||
## Phase 3 — Unify controls
|
||||
|
||||
- [ ] Extract `ColumnToggleList` component (built on `DropdownMenuCheckboxItem`, not hand-rolled div+Check) and point at it from:
|
||||
- [ ] `components/ui/sidebar/SidebarColumnSwitch.vue`
|
||||
- [ ] `components/shot/ShotTableToolbar.vue`
|
||||
- [ ] `components/asset/AssetTableToolbar.vue`
|
||||
- [ ] `components/task/TaskTableToolbar.vue`
|
||||
- [ ] Extract shared `EntityTableToolbar` (search, column-visibility trigger, detail-panel toggle, clear-filters) to de-duplicate the three toolbar files
|
||||
- [ ] Extract `SegmentedToggle` component for the three hand-built view-mode switches (shot grid/list/table, asset grid/list, task all/shots/assets)
|
||||
- [ ] Standardize delete confirmation on the shot pattern (`Dialog` + `Alert` + impact summary + type-to-confirm) for any cascading delete; fix asset's dialog to match or consciously keep it lighter
|
||||
- [ ] Swap hand-rolled checkbox-divs for real `Checkbox`/`DropdownMenuCheckboxItem` in:
|
||||
- [ ] `SidebarColumnSwitch.vue`
|
||||
- [ ] `ShotTableToolbar.vue`
|
||||
- [ ] `AssetTableToolbar.vue`
|
||||
- [ ] `TaskTableToolbar.vue`
|
||||
- [ ] `ShotTaskStatusFilter.vue`
|
||||
- [ ] `asset/TaskStatusFilter.vue`
|
||||
- [ ] Replace ad hoc `size="sm" class="h-8 w-8 p-0"` with `size="icon-sm"` across toolbar/browser icon buttons
|
||||
- [ ] Decide one convention (icon+label vs. icon-only+tooltip) for primary "create" CTAs and apply consistently (shot/asset create buttons currently lack tooltips project/episode create buttons have)
|
||||
- [ ] Standardize row-actions/bulk-actions on `DropdownMenu`/`DropdownMenuItem`; replace ad hoc `<button>` list in `components/task/TaskBulkActionsMenu.vue`
|
||||
- [ ] Extract `DetailPanelShell` (header/close button, loading state, error state, Tabs scaffold, slide-in transition) for `ShotDetailPanel.vue`, `AssetDetailPanel.vue`, `TaskDetailPanel.vue` to share; add missing error state to Task's panel
|
||||
|
||||
## Phase 4 — Architecture & tooling investment
|
||||
|
||||
- [ ] Add a shared `useAsyncAction`-style composable (`isLoading`/`error`/`run(fn)`) and adopt it across `stores/*.ts` to cut repeated try/catch/finally boilerplate
|
||||
- [ ] Replace `stores/taskStatuses.ts`'s polling-based in-flight request de-dup (100ms `setInterval` loop, lines 96-117) with promise memoization
|
||||
- [ ] Extend a similar TTL/cache strategy to `stores/assets.ts`, `stores/tasks.ts`, `stores/projects.ts`, `stores/episodes.ts` (currently no caching — every view re-fetches on mount)
|
||||
- [ ] De-duplicate `assets.ts`'s optimistic-update/rollback logic between single (`lines 156-234`) and bulk (`lines 236-356`) task-status updates into one parameterized function
|
||||
- [ ] Add `markRaw()` around icon components stored in reactive state — `stores/projects.ts` `assignProjectIcon`
|
||||
- [ ] Audit `deep: true` watchers, especially `views/ProjectsView.vue:770` (watches the full, non-`markRaw`'d projects array)
|
||||
- [ ] Fix N+1 project-member fetch: hoist `getProjectMembers()` call out of `EditableTaskStatus.vue` (mounted per row × task-type column) into a shared store/cache or parent-passed prop
|
||||
- [ ] Add table virtualization (e.g. `@tanstack/vue-virtual`) to `ShotsDataTable.vue`, `AssetsDataTable.vue`, `TasksDataTable.vue`
|
||||
- [ ] Introduce a shared `EmptyState` component and standardize on `Skeleton` for loading states (currently: 5 files use `Skeleton`, 62 use ad hoc spinners; 31 files hand-roll empty-state text)
|
||||
- [ ] Add toast-on-error for currently-silent secondary loads: episodes/task-types/task-statuses/project-context in `ShotBrowser.vue` (lines 474-508), `stores/notifications.ts:fetchStats`, router auth-init failure (`router/index.ts:190-196`)
|
||||
- [ ] Centralize a `usePermission()`/`can()` composable to consolidate role/admin checks currently duplicated across ~20 components outside the router guard
|
||||
- [ ] Consider splitting the largest files into smaller pieces: `views/project/ShotDetailView.vue` (1658 lines), `views/admin/DeletedItemsManagementView.vue` (1279 lines), and evaluate a shared "entity browser" composable for `ShotBrowser.vue`/`AssetBrowser.vue`'s ~70% structural overlap
|
||||
- [ ] Move ad hoc domain types out of `services/*.ts` into `types/` for discoverability (currently only `auth.ts`, `notification.ts`, `activity.ts` live in `types/`)
|
||||
|
||||
## Phase 5 — Testing & quality gates
|
||||
|
||||
- [ ] Add Vitest + Vue Test Utils
|
||||
- [ ] Write first tests for the highest-risk untested logic: `stores/assets.ts` optimistic-update/rollback, `stores/auth.ts` token lifecycle
|
||||
- [ ] Add an ESLint config (flag `any`, unused locals, enforce consistent import/style rules)
|
||||
- [ ] Re-enable `noUnusedLocals` in `tsconfig.json` (currently explicitly disabled)
|
||||
- [ ] Introduce a typed `ApiError` helper to replace ad hoc `err.response?.data?.detail` access at the 104 `catch (err: any)` sites (fix incrementally as files are touched, not all at once)
|
||||
- [ ] Accessibility fixes: remove checkbox-inside-`SelectItem` anti-pattern (also fixes the double-toggle bug), add keyboard support to hand-rolled clickable `<div>`s (`SubmissionCard.vue`, `EditableTaskStatus.vue`)
|
||||
|
||||
---
|
||||
|
||||
*Full context, rationale, and file-by-file findings for every item above are in `frontend_report.md`.*
|
||||
Reference in New Issue
Block a user