Compare commits

..

4 Commits

Author SHA1 Message Date
indigo cd3628a255 Phase 2: asset/shot feature parity, task actions, member management
Phase 2 of frontend_tasks.md - close feature gaps between domains:

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 04:46:43 +08:00
49 changed files with 1692 additions and 2027 deletions
+9
View File
@@ -477,6 +477,15 @@ async def get_asset(
asset_data = AssetResponse.model_validate(asset) asset_data = AssetResponse.model_validate(asset)
asset_data.task_count = task_count 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 return asset_data
+1 -1
View File
@@ -66,7 +66,7 @@ async def get_pending_reviews(
query = db.query(Submission).options( query = db.query(Submission).options(
joinedload(Submission.user), joinedload(Submission.user),
joinedload(Submission.task).joinedload(Task.project), joinedload(Submission.task).joinedload(Task.project),
joinedload(Submission.reviews).joinedload("reviewer") joinedload(Submission.reviews).joinedload(Review.reviewer)
).join(Task).filter( ).join(Task).filter(
Submission.deleted_at.is_(None), Submission.deleted_at.is_(None),
Task.deleted_at.is_(None) Task.deleted_at.is_(None)
+8 -7
View File
@@ -25,6 +25,13 @@ class AssetUpdate(BaseModel):
status: Optional[AssetStatus] = None status: Optional[AssetStatus] = None
class TaskStatusInfo(BaseModel):
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class AssetResponse(AssetBase): class AssetResponse(AssetBase):
id: int id: int
project_id: int project_id: int
@@ -33,18 +40,12 @@ class AssetResponse(AssetBase):
# Summary information # Summary information
task_count: int = 0 task_count: int = 0
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
class Config: class Config:
from_attributes = True from_attributes = True
class TaskStatusInfo(BaseModel):
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
task_id: Optional[int] = None
assigned_user_id: Optional[int] = None
class AssetListResponse(BaseModel): class AssetListResponse(BaseModel):
id: int id: int
name: str name: str
+1 -2
View File
@@ -302,10 +302,9 @@ class ActivityService:
"""Get activities excluding those related to deleted records.""" """Get activities excluding those related to deleted records."""
from sqlalchemy import and_, or_, desc from sqlalchemy import and_, or_, desc
from datetime import datetime, timedelta from datetime import datetime, timedelta
from models.task import Task from models.task import Task, Submission
from models.shot import Shot from models.shot import Shot
from models.asset import Asset from models.asset import Asset
from models.submission import Submission
query = db.query(Activity) 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>
+100 -69
View File
@@ -16,6 +16,7 @@
:selected-asset="selectedAsset" :selected-asset="selectedAsset"
:is-detail-panel-enabled="isDetailPanelEnabled" :is-detail-panel-enabled="isDetailPanelEnabled"
:show-thumbnails="showThumbnails" :show-thumbnails="showThumbnails"
:is-columns-locked="lockColumns"
@update:view-mode="viewMode = $event" @update:view-mode="viewMode = $event"
@update:category-filter="handleCategoryFilterChange" @update:category-filter="handleCategoryFilterChange"
@update:search="searchQuery = $event" @update:search="searchQuery = $event"
@@ -23,6 +24,7 @@
@update:show-thumbnails="handleThumbnailToggle" @update:show-thumbnails="handleThumbnailToggle"
@task-status-filter-changed="handleTaskStatusFilter" @task-status-filter-changed="handleTaskStatusFilter"
@toggle-detail-panel="toggleDetailPanelEnabled" @toggle-detail-panel="toggleDetailPanelEnabled"
@toggle-column-lock="toggleColumnLock"
@create-asset="showCreateDialog = true" @create-asset="showCreateDialog = true"
/> />
<div v-else class="flex items-center justify-center py-4"> <div v-else class="flex items-center justify-center py-4">
@@ -109,6 +111,7 @@
:sorting="sorting" :sorting="sorting"
:column-visibility="columnVisibility" :column-visibility="columnVisibility"
:all-task-types="allTaskTypes" :all-task-types="allTaskTypes"
:lock-columns="lockColumns"
@update:sorting="sorting = $event" @update:sorting="sorting = $event"
@update:column-visibility="handleColumnVisibilityChange" @update:column-visibility="handleColumnVisibilityChange"
@update:rowSelection="handleRowSelectionChange" @update:rowSelection="handleRowSelectionChange"
@@ -161,26 +164,14 @@
</Dialog> </Dialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog"> <AssetDeleteConfirmDialog
<AlertDialogContent> v-if="deletionInfo && assetToDelete"
<AlertDialogHeader> :open="showDeleteDialog"
<AlertDialogTitle>Delete Asset</AlertDialogTitle> :asset-id="assetToDelete.id"
<AlertDialogDescription> :asset-name="deletionInfo.asset_name"
Are you sure you want to delete "{{ selectedAsset?.name }}"? This @update:open="showDeleteDialog = $event"
action cannot be undone and will remove all associated tasks. @confirm-delete="handleDeleteAsset"
</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>
</div> </div>
<!-- Asset Detail Panel (Desktop) with slide animation --> <!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition <Transition
@@ -195,17 +186,23 @@
v-if="showPanel && selectedAsset" v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto" class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
> >
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel <AssetDetailPanel
v-else
:project-id="projectId" :project-id="projectId"
:asset-id="selectedAsset.id" :asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel" @close="closeDetailPanel"
@edit="editAsset" @edit="editAsset"
@delete="deleteAsset" @delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask" @select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/> />
</div> </div>
</Transition> </Transition>
@@ -213,18 +210,23 @@
<!-- Asset Detail Panel (Mobile) --> <!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail"> <Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0"> <SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel <AssetDetailPanel
v-if="selectedAsset" v-else-if="selectedAsset"
:project-id="projectId" :project-id="projectId"
:asset-id="selectedAsset.id" :asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel" @close="closeDetailPanel"
@edit="editAsset" @edit="editAsset"
@delete="deleteAsset" @delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask" @select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/> />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -251,20 +253,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { Sheet, SheetContent } from "@/components/ui/sheet";
import AssetCard from "./AssetCard.vue"; import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue"; import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue"; import AssetDetailPanel from "./AssetDetailPanel.vue";
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
import AssetsDataTable from "./AssetsDataTable.vue"; import AssetsDataTable from "./AssetsDataTable.vue";
import AssetTableToolbar from "./AssetTableToolbar.vue"; import AssetTableToolbar from "./AssetTableToolbar.vue";
import { createAssetColumns, type AssetColumnMeta } from "./columns"; import { createAssetColumns, type AssetColumnMeta } from "./columns";
@@ -273,11 +267,13 @@ import { useAuthStore } from "@/stores/auth";
import { useTaskStatusesStore } from "@/stores/taskStatuses"; import { useTaskStatusesStore } from "@/stores/taskStatuses";
import { useDetailPanel } from "@/composables/useDetailPanel"; import { useDetailPanel } from "@/composables/useDetailPanel";
import { import {
assetService,
AssetCategory, AssetCategory,
TaskStatus, TaskStatus,
type Asset, type Asset,
type AssetCreate, type AssetCreate,
type AssetUpdate, type AssetUpdate,
type AssetDeletionInfo,
} from "@/services/asset"; } from "@/services/asset";
import { useToast } from "@/components/ui/toast/use-toast"; import { useToast } from "@/components/ui/toast/use-toast";
import type { SortingState, VisibilityState } from '@tanstack/vue-table'; import type { SortingState, VisibilityState } from '@tanstack/vue-table';
@@ -385,6 +381,9 @@ const showDeleteDialog = ref(false);
const isCreating = ref(false); const isCreating = ref(false);
const isUpdating = ref(false); const isUpdating = ref(false);
const assetToDelete = ref<Asset | null>(null);
const deletionInfo = ref<AssetDeletionInfo | null>(null);
const taskStatusFilter = ref('') const taskStatusFilter = ref('')
// Thumbnail display state - with session storage // Thumbnail display state - with session storage
@@ -392,6 +391,13 @@ const showThumbnails = ref(
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true' sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
); );
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
const toggleColumnLock = () => {
lockColumns.value = !lockColumns.value;
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
};
// Computed properties // Computed properties
const assets = computed(() => assetsStore.assets); const assets = computed(() => assetsStore.assets);
const isLoading = computed(() => assetsStore.isLoading); const isLoading = computed(() => assetsStore.isLoading);
@@ -451,6 +457,7 @@ const assetColumns = computed(() => {
onDelete: deleteAsset, onDelete: deleteAsset,
onViewTasks: viewAssetTasks, onViewTasks: viewAssetTasks,
onTaskStatusUpdated: handleTaskStatusUpdate, onTaskStatusUpdated: handleTaskStatusUpdate,
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
onBulkTaskStatusChange: handleBulkTaskStatusChange, onBulkTaskStatusChange: handleBulkTaskStatusChange,
getSelectedCount: () => selectedCount.value, getSelectedCount: () => selectedCount.value,
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId) getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
@@ -563,14 +570,25 @@ const editAsset = (asset: Asset) => {
showEditDialog.value = true; showEditDialog.value = true;
}; };
const deleteAsset = (asset: Asset) => { const deleteAsset = async (asset: Asset) => {
selectedAsset.value = 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; 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) => { const viewAssetTasks = (asset: Asset) => {
// TODO: Navigate to asset tasks view // Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
console.log("View tasks for asset:", asset.name); selectAsset(asset);
}; };
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => { const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
@@ -619,15 +637,21 @@ const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
}; };
const handleDeleteAsset = async () => { const handleDeleteAsset = async () => {
if (!selectedAsset.value) return; if (!assetToDelete.value) return;
try { try {
await assetsStore.deleteAsset(selectedAsset.value.id); await assetsStore.deleteAsset(assetToDelete.value.id);
showDeleteDialog.value = false; showDeleteDialog.value = false;
selectedAsset.value = null; assetToDelete.value = null;
const taskCount = deletionInfo.value?.task_count || 0;
deletionInfo.value = null;
toast({ toast({
title: "Asset deleted", 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) { } catch (err) {
toast({ 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 = () => { const clearFilters = () => {
selectedCategory.value = "all"; selectedCategory.value = "all";
searchQuery.value = ""; searchQuery.value = "";
@@ -677,30 +719,19 @@ const formatTaskType = (taskType: string) => {
}; };
// Detail panel event handlers // Detail panel event handlers
const handleCreateTask = () => { const selectedTaskId = ref<number | null>(null);
// TODO: Navigate to task creation for this asset const selectedTaskTab = ref<string>('infos');
console.log('Create task for asset:', selectedAsset.value?.name);
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id;
selectedTaskTab.value = tab || 'infos';
}; };
const handleSelectTask = (task: any) => { // Reset the task sub-panel whenever the asset selection changes (including close)
// TODO: Open task detail panel watch(selectedAsset, () => {
console.log('Select task:', task); selectedTaskId.value = null;
}; selectedTaskTab.value = 'infos';
});
const handleCreateNote = () => {
// TODO: Open note creation dialog
console.log('Create note for asset:', selectedAsset.value?.name);
};
const handleUploadReference = () => {
// TODO: Open reference upload dialog
console.log('Upload reference for asset:', selectedAsset.value?.name);
};
const handlePublishVersion = () => {
// TODO: Open version publish dialog
console.log('Publish version for asset:', selectedAsset.value?.name);
};
// Load custom task types from project // Load custom task types from project
const loadCustomTaskTypes = async () => { const loadCustomTaskTypes = async () => {
@@ -21,13 +21,11 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4"> <Alert v-else-if="loadError" variant="destructive">
<div class="flex items-center gap-2 mb-2"> <AlertCircle class="h-4 w-4" />
<AlertCircle class="h-4 w-4 text-destructive" /> <AlertTitle>Failed to load deletion information</AlertTitle>
<span class="font-medium text-destructive">Failed to load deletion information</span> <AlertDescription>{{ loadError }}</AlertDescription>
</div> </Alert>
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
</div>
<!-- Deletion Information --> <!-- Deletion Information -->
<div v-else-if="deletionInfo" class="space-y-4"> <div v-else-if="deletionInfo" class="space-y-4">
@@ -73,15 +71,13 @@
</div> </div>
<!-- Affected Users --> <!-- Affected Users -->
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4"> <Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
<div class="flex items-center gap-2 mb-3">
<Users class="h-4 w-4 text-orange-600" /> <Users class="h-4 w-4 text-orange-600" />
<span class="font-medium text-orange-800"> <AlertTitle class="text-orange-800">
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected {{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
</span> </AlertTitle>
</div> <AlertDescription class="text-orange-700">
<p class="mb-3">
<p class="text-sm text-orange-700 mb-3">
The following users have work associated with this asset that will be marked as deleted: The following users have work associated with this asset that will be marked as deleted:
</p> </p>
@@ -93,7 +89,7 @@
> >
<div class="flex-1"> <div class="flex-1">
<div class="font-medium text-sm">{{ user.name }}</div> <div class="font-medium text-sm">{{ user.name }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div> <div class="text-xs text-muted-foreground">{{ user.email }} {{ user.role }}</div>
</div> </div>
<div class="text-xs text-muted-foreground text-right"> <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.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
@@ -105,27 +101,26 @@
</div> </div>
</div> </div>
</div> </div>
</div> </AlertDescription>
</Alert>
<!-- No affected users --> <!-- No affected users -->
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4"> <Alert v-else variant="default" class="border-green-200 bg-green-50">
<div class="flex items-center gap-2">
<CheckCircle class="h-4 w-4 text-green-600" /> <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> <AlertDescription class="text-green-800">
</div> No users will be affected by this deletion.
</div> </AlertDescription>
</Alert>
<!-- Data Preservation Notice --> <!-- Data Preservation Notice -->
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4"> <Alert variant="default" class="border-blue-200 bg-blue-50">
<div class="flex items-center gap-2 mb-2">
<Shield class="h-4 w-4 text-blue-600" /> <Shield class="h-4 w-4 text-blue-600" />
<span class="font-medium text-blue-800">Data Preservation</span> <AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
</div> <AlertDescription class="text-blue-700">
<p class="text-sm text-blue-700">
All data will be preserved in the database and can be recovered by administrators. 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. Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
</p> </AlertDescription>
</div> </Alert>
<!-- Confirmation input --> <!-- Confirmation input -->
<div class="space-y-2"> <div class="space-y-2">
@@ -189,6 +184,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/ui/alert'
import { assetService, type AssetDeletionInfo } from '@/services/asset' import { assetService, type AssetDeletionInfo } from '@/services/asset'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -275,7 +275,7 @@ watch(() => props.open, (newOpen) => {
deletionInfo.value = null deletionInfo.value = null
loadError.value = null loadError.value = null
} }
}) }, { immediate: true })
const handleDelete = async () => { const handleDelete = async () => {
if (!isConfirmed.value) return if (!isConfirmed.value) return
@@ -117,7 +117,110 @@
<!-- Task Status & Assignees --> <!-- Task Status & Assignees -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3> <h3 class="text-sm font-semibold">Tasks</h3>
<div class="flex items-center gap-1">
<!-- Add Task -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
<Plus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
All task types already added
</div>
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="type in availableTaskTypes"
:key="type"
variant="ghost"
size="sm"
class="justify-start"
:disabled="isCreatingTask"
@click="handleAddTask(type)"
>
{{ formatTaskType(type) }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Add Note (picks a task, opens its Notes tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
<MessageSquarePlus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
<Paperclip class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'attachments')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Publish Version (picks a task, opens its Submissions tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'submissions')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- Loading Tasks --> <!-- Loading Tasks -->
<div v-if="isLoading" class="flex items-center justify-center py-4"> <div v-if="isLoading" class="flex items-center justify-center py-4">
@@ -183,16 +286,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
AlertCircle, RefreshCw, X AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import AssetNotes from './AssetNotes.vue' import AssetNotes from './AssetNotes.vue'
import AssetReferences from './AssetReferences.vue' import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset' import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService } from '@/services/task'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@@ -208,16 +313,13 @@ interface Task {
interface Props { interface Props {
projectId: number projectId: number
assetId: number assetId: number
allTaskTypes: string[]
} }
interface Emits { interface Emits {
(e: 'edit', asset: Asset): void (e: 'edit', asset: Asset): void
(e: 'delete', asset: Asset): void (e: 'delete', asset: Asset): void
(e: 'create-task'): void (e: 'select-task', task: Task, tab?: string): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'upload-reference'): void
(e: 'publish-version'): void
(e: 'close'): void (e: 'close'): void
} }
@@ -233,6 +335,7 @@ const notes = ref<any[]>([])
const references = ref<any[]>([]) const references = ref<any[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isCreatingTask = ref(false)
// Computed properties // Computed properties
const tasks = computed(() => { const tasks = computed(() => {
@@ -266,6 +369,11 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100) return Math.round((completedTasksCount.value / tasks.value.length) * 100)
}) })
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
})
const taskStatusCounts = computed(() => { const taskStatusCounts = computed(() => {
const counts = { const counts = {
not_started: 0, not_started: 0,
@@ -365,6 +473,18 @@ const loadReferences = async () => {
} }
} }
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createAssetTask(props.assetId, taskType)
await loadAssetDetails()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatCategory = (category: string) => { const formatCategory = (category: string) => {
return category.charAt(0).toUpperCase() + category.slice(1) return category.charAt(0).toUpperCase() + category.slice(1)
} }
@@ -199,6 +199,19 @@
<PanelRightOpen v-else class="h-4 w-4" /> <PanelRightOpen v-else class="h-4 w-4" />
</Button> </Button>
<!-- Lock First Columns Toggle Button (only for list view) -->
<Button
v-if="viewMode === 'list'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
<Unlock v-else class="h-4 w-4" />
</Button>
<!-- Clear Filters --> <!-- Clear Filters -->
<Button <Button
v-if="hasFilters" v-if="hasFilters"
@@ -250,7 +263,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { import {
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff, LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
Lock, Unlock
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -290,6 +304,7 @@ interface Props {
selectedAsset: Asset | null selectedAsset: Asset | null
isDetailPanelEnabled: boolean isDetailPanelEnabled: boolean
showThumbnails: boolean showThumbnails: boolean
isColumnsLocked: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -302,6 +317,7 @@ const emit = defineEmits<{
'update:show-thumbnails': [value: boolean] 'update:show-thumbnails': [value: boolean]
'task-status-filter-changed': [value: string] 'task-status-filter-changed': [value: string]
'toggle-detail-panel': [] 'toggle-detail-panel': []
'toggle-column-lock': []
'create-asset': [] 'create-asset': []
}>() }>()
@@ -1,7 +1,105 @@
<template> <template>
<div class="space-y-4 px-4"> <div class="space-y-4 px-4">
<div class="rounded-md border"> <div class="rounded-md border">
<Table> <!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns">
<div v-if="hasRows" class="flex">
<!-- Left pane: frozen columns, no horizontal scroll -->
<div class="flex-shrink-0 border-r">
<table class="caption-bottom text-sm">
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
:key="header.id"
:style="frozenWidth(header.column.id)"
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in frozenCells(row)"
:key="cell.id"
:style="frozenWidth(cell.column.id)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
<!-- Right pane: movable columns, own horizontal scrollbar -->
<div class="flex-1 min-w-0 overflow-x-auto">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
:key="header.id"
:class="[
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
header.column.id === 'actions' ? 'w-12' : '',
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
]"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in movableCells(row)"
:key="cell.id"
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
</div>
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
No results.
</div>
</template>
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader> <TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
@@ -67,13 +165,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
FlexRender, FlexRender,
getCoreRowModel, getCoreRowModel,
getSortedRowModel, getSortedRowModel,
useVueTable, useVueTable,
type ColumnDef, type ColumnDef,
type HeaderGroup,
type Row,
type SortingState, type SortingState,
type VisibilityState, type VisibilityState,
} from '@tanstack/vue-table' } from '@tanstack/vue-table'
@@ -93,10 +193,38 @@ interface Props {
sorting: SortingState sorting: SortingState
columnVisibility: VisibilityState columnVisibility: VisibilityState
allTaskTypes: string[] allTaskTypes: string[]
lockColumns?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
// Frozen (locked) columns and their fixed widths (px), in column order.
const FROZEN: Record<string, number> = {
select: 48,
thumbnail: 96,
name: 200,
}
const isFrozen = (id: string) => id in FROZEN
const frozenWidth = (id: string) => {
const w = FROZEN[id]
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
}
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
// respect column visibility; frozen ones are always shown.
const frozenHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
const movableHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
const frozenCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
const movableCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
const hasRows = computed(() => table.getRowModel().rows.length > 0)
const emit = defineEmits<{ const emit = defineEmits<{
'update:sorting': [sorting: SortingState] 'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState] 'update:columnVisibility': [visibility: VisibilityState]
@@ -1,256 +0,0 @@
<template>
<div class="flex items-center gap-2">
<!-- Global Task Columns Toggle Button -->
<Button
variant="outline"
size="sm"
@click="toggleAllTaskColumns"
class="h-9"
>
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
<ListX v-else class="h-4 w-4 mr-2" />
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
</Button>
<!-- Column Visibility Dropdown -->
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Toggle columns">
<div class="flex items-center gap-2">
<Columns class="h-4 w-4" />
<span>Columns</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="toggle">Toggle Columns</SelectItem>
<SelectGroup>
<SelectLabel>Basic Columns</SelectLabel>
<SelectItem value="thumbnail" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.thumbnail"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('thumbnail', val)"
/>
<span>Thumbnail</span>
</div>
</SelectItem>
<SelectItem value="name" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.name"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('name', val)"
/>
<span>Name</span>
</div>
</SelectItem>
<SelectItem value="category" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.category"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('category', val)"
/>
<span>Category</span>
</div>
</SelectItem>
<SelectItem value="status" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.status"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('status', val)"
/>
<span>Status</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Task Status Columns</SelectLabel>
<!-- Standard Task Types -->
<SelectItem value="modeling" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.modeling"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('modeling', val)"
/>
<span>Modeling</span>
</div>
</SelectItem>
<SelectItem value="surfacing" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.surfacing"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('surfacing', val)"
/>
<span>Surfacing</span>
</div>
</SelectItem>
<SelectItem value="rigging" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.rigging"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('rigging', val)"
/>
<span>Rigging</span>
</div>
</SelectItem>
<!-- Custom Task Types -->
<SelectItem
v-for="customType in customTaskTypes"
:key="customType"
:value="customType"
@click.stop
>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns[customType]"
@update:checked="(val: boolean | 'indeterminate') => updateColumn(customType, val)"
/>
<span>{{ formatTaskType(customType) }}</span>
</div>
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Other Columns</SelectLabel>
<SelectItem value="description" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.description"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('description', val)"
/>
<span>Description</span>
</div>
</SelectItem>
<SelectItem value="updatedAt" @click.stop>
<div class="flex items-center gap-2">
<Checkbox
v-model="visibleColumns.updatedAt"
@update:checked="(val: boolean | 'indeterminate') => updateColumn('updatedAt', val)"
/>
<span>Updated</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Columns, ListTodo, ListX } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface Props {
visibleColumns: Record<string, boolean>;
projectId?: number;
}
interface Emits {
(e: "update:visibleColumns", columns: Record<string, boolean>): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const selectedColumn = ref('toggle')
const customTaskTypes = ref<string[]>([])
const savedTaskColumnStates = ref<Record<string, boolean>>({})
// Standard task types
const standardTaskTypes = ['modeling', 'surfacing', 'rigging']
// All task types (standard + custom)
const allTaskTypes = computed(() => [...standardTaskTypes, ...customTaskTypes.value])
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
return allTaskTypes.value.every(taskType => props.visibleColumns[taskType])
})
// Load custom task types from project
const loadCustomTaskTypes = async () => {
if (!props.projectId) return
try {
const { projectService } = await import('@/services/project')
const project = await projectService.getProject(props.projectId)
customTaskTypes.value = project.custom_asset_task_types || []
// Initialize visibility for custom task types if not already set
const newColumns = { ...props.visibleColumns }
let hasChanges = false
for (const customType of customTaskTypes.value) {
if (!(customType in newColumns)) {
newColumns[customType] = true // Show custom types by default
hasChanges = true
}
}
if (hasChanges) {
emit("update:visibleColumns", newColumns)
}
} catch (error) {
console.warn('Could not load custom task types:', error)
// Continue without custom task types
}
}
// Toggle all task columns show/hide
const toggleAllTaskColumns = () => {
const newColumns = { ...props.visibleColumns }
if (allTaskColumnsVisible.value) {
// Hide all task columns but save their states
savedTaskColumnStates.value = {}
for (const taskType of allTaskTypes.value) {
savedTaskColumnStates.value[taskType] = newColumns[taskType]
newColumns[taskType] = false
}
} else {
// Restore saved states or show all
for (const taskType of allTaskTypes.value) {
if (taskType in savedTaskColumnStates.value) {
newColumns[taskType] = savedTaskColumnStates.value[taskType]
} else {
newColumns[taskType] = true
}
}
savedTaskColumnStates.value = {}
}
emit("update:visibleColumns", newColumns)
}
const handleColumnToggle = () => {
// Reset selection after interaction
selectedColumn.value = 'toggle'
}
const updateColumn = (column: string, checked: boolean | 'indeterminate') => {
const newColumns = { ...props.visibleColumns }
newColumns[column] = checked === true
emit('update:visibleColumns', newColumns)
}
const formatTaskType = (taskType: string) => {
return taskType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
onMounted(() => {
loadCustomTaskTypes()
})
</script>
@@ -1,6 +1,5 @@
<template> <template>
<div class="relative" <div class="relative flex items-center gap-1">
>
<Select <Select
:model-value="currentStatusId" :model-value="currentStatusId"
@update:model-value="handleStatusChange" @update:model-value="handleStatusChange"
@@ -12,12 +11,7 @@
> >
<SelectValue <SelectValue
:model-value="currentStatusId" :model-value="currentStatusId"
> />
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
<!-- console.log(currentStatusObject) -->
<!-- currentStatusObject -->
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
@@ -38,6 +32,112 @@
</SelectContent> </SelectContent>
</Select> </Select>
<!-- User Assignment Button -->
<div @click.stop>
<Popover>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-accent relative"
:disabled="isUpdating"
@click.stop="ensureMembersLoaded"
>
<Avatar class="h-4 w-4" v-if="assignedUser">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<User class="h-3 w-3" v-else />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
<div class="space-y-2">
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
<!-- Current Assignment Display with X button -->
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground">Current</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
@click.stop="handleAssignUser(null)"
:disabled="isAssigning"
>
<X class="h-3 w-3" />
</Button>
</div>
<!-- Search Input -->
<div class="relative">
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search members..."
class="pl-7 h-8 text-xs"
/>
</div>
<!-- Loading state -->
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2 text-sm">Loading members...</span>
</div>
<!-- Error state -->
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
No project members found
<Button
variant="outline"
size="sm"
class="mt-2"
@click="loadProjectMembers"
>
Retry
</Button>
</div>
<!-- Content when members are loaded -->
<template v-else>
<!-- Project members list -->
<div class="max-h-64 overflow-y-auto">
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
No matching members found
</div>
<Button
v-else
v-for="member in filteredProjectMembers"
:key="member.user_id"
variant="ghost"
size="sm"
class="w-full justify-start h-10"
@click="handleAssignUser(member.user_id)"
:disabled="isAssigning"
>
<Avatar class="h-8 w-8 mr-2">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
</div>
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Loading indicator --> <!-- Loading indicator -->
<div <div
v-if="isUpdating || isLoadingStatuses" v-if="isUpdating || isLoadingStatuses"
@@ -57,11 +157,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset' import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task' import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useTaskStatusesStore } from '@/stores/taskStatuses'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus' import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface StatusOption { interface StatusOption {
id: string id: string
@@ -76,19 +187,47 @@ interface Props {
status: TaskStatus | string status: TaskStatus | string
taskId?: number | null taskId?: number | null
projectId: number projectId: number
assignedUserId?: number | null
} }
interface Emits { interface Emits {
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void (e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
(e: 'assignment-updated', assetId: number, taskType: string, userId: number | null): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { getAvatarUrl } = useAvatarUrl()
// Use the shared task statuses store instead of direct API calls // Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore() const taskStatusesStore = useTaskStatusesStore()
const isUpdating = ref(false) const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const searchQuery = ref('')
// Filtered project members based on search query
const filteredProjectMembers = computed(() => {
if (!searchQuery.value.trim()) {
return projectMembers.value
}
const query = searchQuery.value.toLowerCase().trim()
return projectMembers.value.filter(member => {
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
const departmentRole = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || departmentRole.includes(query)
})
})
// Get assigned user info
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
// Get loading state from store // Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId)) const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
@@ -144,6 +283,18 @@ const formatStatusName = (status: string): string => {
} }
} }
// Format department role for display
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
// Get user initials
const getUserInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
// Fetch custom statuses for the project using store // Fetch custom statuses for the project using store
const fetchStatuses = async () => { const fetchStatuses = async () => {
if (!props.projectId) return if (!props.projectId) return
@@ -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) => { const handleStatusChange = async (newStatusId: any) => {
if (!newStatusId || newStatusId === currentStatusId.value) return if (!newStatusId || newStatusId === currentStatusId.value) return
@@ -189,10 +396,14 @@ const handleStatusChange = async (newStatusId: any) => {
// Fetch statuses on mount // Fetch statuses on mount
onMounted(() => { onMounted(() => {
fetchStatuses() fetchStatuses()
// Preload project members to ensure they're available when needed
loadProjectMembers()
}) })
// Refetch statuses when projectId changes // Refetch statuses when projectId changes
watch(() => props.projectId, () => { watch(() => props.projectId, () => {
fetchStatuses() fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
}) })
</script> </script>
+8 -1
View File
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
onDelete: (asset: Asset) => void onDelete: (asset: Asset) => void
onViewTasks: (asset: Asset) => void onViewTasks: (asset: Asset) => void
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
getSelectedCount?: () => number getSelectedCount?: () => number
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }> getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
@@ -225,7 +226,9 @@ export const createAssetColumns = (
cell: ({ row }) => { cell: ({ row }) => {
const asset = row.original const asset = row.original
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id const taskDetail = asset.task_details?.find(t => t.task_type === taskType)
const taskId = taskDetail?.task_id
const assignedUserId = taskDetail?.assigned_user_id || null
return h(EditableTaskStatus, { return h(EditableTaskStatus, {
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
@@ -234,9 +237,13 @@ export const createAssetColumns = (
status, status,
taskId, taskId,
projectId: meta.projectId, projectId: meta.projectId,
assignedUserId,
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => { onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
meta.onTaskStatusUpdated(assetId, taskType, newStatus) meta.onTaskStatusUpdated(assetId, taskType, newStatus)
}, },
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
},
}) })
}, },
enableSorting: true, enableSorting: true,
@@ -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, Key,
Database, Database,
BarChart3, BarChart3,
FileText,
RotateCcw, RotateCcw,
} from 'lucide-vue-next' } from 'lucide-vue-next'
@@ -183,8 +182,7 @@ const developerItems = computed(() => [
{ title: 'API Keys', url: '/developer/api-keys', icon: Key }, { title: 'API Keys', url: '/developer/api-keys', icon: Key },
{ title: 'All Projects', url: '/developer/projects', icon: Database }, { title: 'All Projects', url: '/developer/projects', icon: Database },
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare }, { title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }, { title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
]) ])
// Mock recent projects - this would come from a store in real implementation // Mock recent projects - this would come from a store in real implementation
+26 -37
View File
@@ -65,26 +65,11 @@
<User class="size-4" /> <User class="size-4" />
Profile Profile
</DropdownMenuItem> </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> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<!-- Help and support --> <!-- Help and support -->
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem @click="navigateTo('/help')">
<HelpCircle class="size-4" />
Help & Support
</DropdownMenuItem>
<DropdownMenuItem @click="showKeyboardShortcuts"> <DropdownMenuItem @click="showKeyboardShortcuts">
<Keyboard class="size-4" /> <Keyboard class="size-4" />
Keyboard Shortcuts Keyboard Shortcuts
@@ -99,6 +84,24 @@
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</template> </template>
@@ -111,11 +114,8 @@ import {
LogOut, LogOut,
User, User,
Settings, Settings,
Bell,
Key, Key,
Users, Users,
Palette,
HelpCircle,
Keyboard, Keyboard,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
@@ -132,6 +132,12 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
@@ -181,8 +187,7 @@ const showRoleFeatures = computed(() => {
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
}) })
// Notifications state (this would typically come from a notifications store) const showShortcutsDialog = ref(false)
const notificationsEnabled = ref(true)
// Actions // Actions
const navigateTo = (path: string) => { 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 = () => { const showKeyboardShortcuts = () => {
// In a real app, this would open a modal with keyboard shortcuts showShortcutsDialog.value = true
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.`)
} }
@@ -41,14 +41,7 @@
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar class="h-8 w-8"> <Avatar class="h-8 w-8">
<AvatarImage <AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
v-if="member.user_id"
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
/>
<AvatarImage
v-else
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
/>
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback> <AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
@@ -65,21 +58,21 @@
<!-- Department Role --> <!-- Department Role -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Label class="text-sm">Department:</Label> <Label class="text-sm">Department:</Label>
<select <Select
:value="member.department_role || 'none'" :model-value="member.department_role || 'none'"
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)" @update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
:disabled="isUpdatingMember === member.id" :disabled="isUpdatingMember === member.id"
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
<option value="none">None</option> <SelectTrigger class="w-32 h-8">
<option value="layout">Layout</option> <SelectValue />
<option value="animation">Animation</option> </SelectTrigger>
<option value="lighting">Lighting</option> <SelectContent>
<option value="composite">Composite</option> <SelectItem value="none">None</SelectItem>
<option value="modeling">Modeling</option> <SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
<option value="rigging">Rigging</option> {{ role.label }}
<option value="surfacing">Surfacing</option> </SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<!-- Joined Date --> <!-- Joined Date -->
@@ -89,7 +82,7 @@
<!-- Actions --> <!-- Actions -->
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0"> <Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<MoreHorizontal class="h-4 w-4" /> <MoreHorizontal class="h-4 w-4" />
</Button> </Button>
@@ -118,119 +111,84 @@
</Button> </Button>
</div> </div>
<!-- Add Member Modal --> <!-- Add Member Dialog -->
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center"> <Dialog v-model:open="showAddMemberDialog">
<!-- Backdrop --> <DialogContent class="sm:max-w-md">
<div <DialogHeader>
class="fixed inset-0 bg-black/50" <DialogTitle>Add Team Member</DialogTitle>
@click="closeAddDialog" <DialogDescription>
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Add Team Member</h3>
<p class="text-sm text-muted-foreground">
Add a user to this project and assign their department role. Add a user to this project and assign their department role.
</p> </DialogDescription>
</div> </DialogHeader>
<div class="space-y-4"> <div class="space-y-4">
<!-- User Selection -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="user">User</Label> <Label>User</Label>
<select <Select v-model="newMember.userId" :disabled="isAddingMember">
v-model="newMember.userId" <SelectTrigger>
:disabled="isAddingMember" <SelectValue placeholder="Select a user" />
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" </SelectTrigger>
> <SelectContent>
<option value="">Select a user</option> <SelectItem
<option v-for="user in availableUsers"
v-for="user in (availableUsers || [])"
:key="user.id" :key="user.id"
:value="user.id.toString()" :value="user.id.toString()"
> >
{{ user.first_name }} {{ user.last_name }} ({{ user.email }}) {{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</option> </SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<!-- Department Role -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="department">Department Role (Optional)</Label> <Label>Department Role (Optional)</Label>
<select <Select v-model="newMember.departmentRole" :disabled="isAddingMember">
v-model="newMember.departmentRole" <SelectTrigger>
:disabled="isAddingMember" <SelectValue placeholder="None" />
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" </SelectTrigger>
> <SelectContent>
<option value="">None</option> <SelectItem value="none">None</SelectItem>
<option value="layout">Layout</option> <SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
<option value="animation">Animation</option> {{ role.label }}
<option value="lighting">Lighting</option> </SelectItem>
<option value="composite">Composite</option> </SelectContent>
<option value="modeling">Modeling</option> </Select>
<option value="rigging">Rigging</option>
<option value="surfacing">Surfacing</option>
</select>
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 mt-6"> <DialogFooter>
<Button <Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
variant="outline"
@click="closeAddDialog"
:disabled="isAddingMember"
>
Cancel Cancel
</Button> </Button>
<Button <Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
@click="addMember"
:disabled="!newMember.userId || isAddingMember"
>
<div v-if="isAddingMember" class="flex items-center gap-2"> <div v-if="isAddingMember" class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
<span>Adding...</span> <span>Adding...</span>
</div> </div>
<span v-else>Add Member</span> <span v-else>Add Member</span>
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
<!-- Remove Member Confirmation --> <!-- Remove Member Confirmation -->
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog v-model:open="showRemoveDialog">
<!-- Backdrop --> <AlertDialogContent>
<div <AlertDialogHeader>
class="fixed inset-0 bg-black/50" <AlertDialogTitle>Remove Team Member</AlertDialogTitle>
@click="showRemoveDialog = false" <AlertDialogDescription>
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Remove Team Member</h3>
<p class="text-sm text-muted-foreground">
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project? Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
This action cannot be undone. This action cannot be undone.
</p> </AlertDialogDescription>
</div> </AlertDialogHeader>
<AlertDialogFooter>
<div class="flex justify-end gap-3"> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button <AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
variant="outline"
@click="showRemoveDialog = false"
>
Cancel
</Button>
<Button
@click="confirmRemoveMember"
variant="destructive"
>
Remove Member Remove Member
</Button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
</div> </div>
</template> </template>
@@ -242,14 +200,39 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project' import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user' import { userService } from '@/services/user'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
@@ -259,10 +242,26 @@ interface Props {
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentRoles = [
{ value: 'layout', label: 'Layout' },
{ value: 'animation', label: 'Animation' },
{ value: 'lighting', label: 'Lighting' },
{ value: 'composite', label: 'Composite' },
{ value: 'modeling', label: 'Modeling' },
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
// State // State
const members = ref<ProjectMember[]>([]) const members = ref<ProjectMember[]>([])
const availableUsers = ref<User[]>([]) const allUsers = ref<User[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isUpdatingMember = ref<number | null>(null) const isUpdatingMember = ref<number | null>(null)
@@ -273,7 +272,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
const newMember = ref({ const newMember = ref({
userId: '', userId: '',
departmentRole: '' departmentRole: 'none'
})
// Computed
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map(m => m.user_id))
return allUsers.value.filter(user => user.is_approved && !memberUserIds.has(user.id))
}) })
// Methods // Methods
@@ -284,40 +289,50 @@ const loadMembers = async () => {
members.value = await projectService.getProjectMembers(props.projectId) members.value = await projectService.getProjectMembers(props.projectId)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load members' error.value = err instanceof Error ? err.message : 'Failed to load members'
toast({
title: 'Error',
description: 'Failed to load project members',
variant: 'destructive',
})
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const loadAvailableUsers = async () => { const loadUsers = async () => {
try { try {
const allUsers = await userService.getAllUsers() allUsers.value = await userService.getUsers()
// Filter out users who are already members
const memberUserIds = new Set(members.value.map(m => m.user_id))
availableUsers.value = allUsers.filter(user =>
user.is_approved && !memberUserIds.has(user.id)
)
} catch (err) { } catch (err) {
console.error('Failed to load users:', err) toast({
error.value = 'Failed to load available users' title: 'Error',
availableUsers.value = [] // Ensure it's always an array description: 'Failed to load users',
variant: 'destructive',
})
} }
} }
const updateMemberRole = async (memberId: number, departmentRole: string | null) => { const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
try { try {
isUpdatingMember.value = memberId isUpdatingMember.value = member.id
await projectService.updateProjectMember(props.projectId, memberId, { const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
department_role: departmentRole as any department_role: departmentRole as any
}) })
// Update local state const index = members.value.findIndex(m => m.id === member.id)
const member = members.value.find(m => m.id === memberId) if (index !== -1) {
if (member) { members.value[index] = updatedMember
member.department_role = departmentRole as any
} }
toast({
title: 'Role updated',
description: 'Member department role has been updated',
})
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update member role' toast({
title: 'Error',
description: 'Failed to update member role',
variant: 'destructive',
})
} finally { } finally {
isUpdatingMember.value = null isUpdatingMember.value = null
} }
@@ -330,7 +345,7 @@ const addMember = async () => {
isAddingMember.value = true isAddingMember.value = true
const memberData = { const memberData = {
user_id: parseInt(newMember.value.userId), user_id: parseInt(newMember.value.userId),
department_role: newMember.value.departmentRole || undefined department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
} }
const addedMember = await projectService.addProjectMember(props.projectId, memberData) const addedMember = await projectService.addProjectMember(props.projectId, memberData)
@@ -338,10 +353,16 @@ const addMember = async () => {
closeAddDialog() closeAddDialog()
// Refresh available users toast({
await loadAvailableUsers() title: 'Member added',
description: 'Team member has been added to the project',
})
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to add member' toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to add member',
variant: 'destructive',
})
} finally { } finally {
isAddingMember.value = false isAddingMember.value = false
} }
@@ -358,19 +379,24 @@ const confirmRemoveMember = async () => {
try { try {
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id) await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
// Remove from local state
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id) const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
if (index !== -1) { if (index !== -1) {
members.value.splice(index, 1) members.value.splice(index, 1)
} }
toast({
title: 'Member removed',
description: 'Team member has been removed from the project',
})
} catch (err) {
toast({
title: 'Error',
description: 'Failed to remove member',
variant: 'destructive',
})
} finally {
showRemoveDialog.value = false showRemoveDialog.value = false
memberToRemove.value = null memberToRemove.value = null
// Refresh available users
await loadAvailableUsers()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to remove member'
} }
} }
@@ -378,10 +404,6 @@ const getUserInitials = (member: ProjectMember) => {
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase() return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
} }
import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@@ -391,23 +413,15 @@ const formatDate = (dateString: string) => {
} }
const openAddDialog = async () => { const openAddDialog = async () => {
try { await loadUsers()
await loadAvailableUsers()
showAddMemberDialog.value = true showAddMemberDialog.value = true
} catch (err) {
console.error('Failed to open add dialog:', err)
error.value = 'Failed to load user list'
}
} }
const closeAddDialog = () => { const closeAddDialog = () => {
showAddMemberDialog.value = false showAddMemberDialog.value = false
// Reset form newMember.value = { userId: '', departmentRole: 'none' }
newMember.value = { userId: '', departmentRole: '' }
} }
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
loadMembers() loadMembers()
@@ -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>
@@ -103,48 +103,30 @@ const tabs = computed<Tab[]>(() => [
const activeTab = computed(() => { const activeTab = computed(() => {
const currentPath = route.path; const currentPath = route.path;
// Debug logging
console.log('ProjectTabs - Current path:', currentPath);
console.log('ProjectTabs - Project ID:', props.projectId);
if (currentPath === `/projects/${props.projectId}`) { if (currentPath === `/projects/${props.projectId}`) {
console.log('ProjectTabs - Active tab: overview');
return "overview"; return "overview";
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) { } else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
console.log('ProjectTabs - Active tab: shots');
return "shots"; return "shots";
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) { } else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
console.log('ProjectTabs - Active tab: assets');
return "assets"; return "assets";
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) { } else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
console.log('ProjectTabs - Active tab: tasks');
return "tasks"; return "tasks";
} else if ( } else if (
currentPath.startsWith(`/projects/${props.projectId}/settings`) currentPath.startsWith(`/projects/${props.projectId}/settings`)
) { ) {
console.log('ProjectTabs - Active tab: settings');
return "settings"; return "settings";
} }
console.log('ProjectTabs - Active tab: overview (default)');
return "overview"; return "overview";
}); });
// Set active tab and navigate // Set active tab and navigate
const setActiveTab = (tabId: string) => { const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId); 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) { if (tab) {
console.log('ProjectTabs - Navigating to:', tab.route); router.push(tab.route).catch(() => {
router.push(tab.route).catch(err => { // Navigation aborted (e.g. duplicate route) safe to ignore
console.error('ProjectTabs - Navigation error:', err);
}); });
} 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) => { const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
console.log('=== HANDLE DELETE ===')
console.log('Received taskType:', taskType)
console.log('Received category:', category)
taskTypeToDelete.value = taskType taskTypeToDelete.value = taskType
categoryToDelete.value = category categoryToDelete.value = category
deleteError.value = '' deleteError.value = ''
isDeleteDialogOpen.value = true isDeleteDialogOpen.value = true
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
} }
const closeDeleteDialog = () => { const closeDeleteDialog = () => {
console.log('=== CLOSE DELETE DIALOG ===')
isDeleteDialogOpen.value = false isDeleteDialogOpen.value = false
// Values will be cleared by @update:open handler // Values will be cleared by @update:open handler
} }
@@ -456,30 +448,17 @@ const confirmDelete = async () => {
isDeleting.value = true isDeleting.value = true
deleteError.value = '' deleteError.value = ''
console.log('=== DELETE DEBUG ===')
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
console.log('categoryToDelete.value:', categoryToDeleteLocal)
console.log('projectId:', props.projectId)
if (!taskTypeToDeleteLocal) { if (!taskTypeToDeleteLocal) {
console.error('ERROR: taskTypeToDelete is empty!')
deleteError.value = 'Task type name is missing. Please try again.' deleteError.value = 'Task type name is missing. Please try again.'
isDeleting.value = false isDeleting.value = false
return return
} }
console.log('Deleting task type:', {
projectId: props.projectId,
taskType: taskTypeToDeleteLocal,
category: categoryToDeleteLocal
})
const response = await customTaskTypeService.deleteCustomTaskType( const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId, props.projectId,
taskTypeToDeleteLocal, taskTypeToDeleteLocal,
categoryToDeleteLocal categoryToDeleteLocal
) )
console.log('Delete task type response:', response)
taskTypes.value = response taskTypes.value = response
toast({ toast({
+32 -10
View File
@@ -173,13 +173,22 @@
v-if="showPanel && selectedShot" v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto" class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
> >
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel <ShotDetailPanel
v-else
:project-id="projectId" :project-id="projectId"
:shot-id="selectedShot.id" :shot-id="selectedShot.id"
:initial-shot="selectedShot" :initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot" @edit="editShot"
@delete="deleteShot" @delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask" @select-task="handleSelectTask"
@close="closeDetailPanel" @close="closeDetailPanel"
/> />
@@ -190,14 +199,22 @@
<!-- Mobile Detail Sheet --> <!-- Mobile Detail Sheet -->
<Sheet v-model:open="showMobileDetail"> <Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0"> <SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel <ShotDetailPanel
v-if="selectedShot" v-else-if="selectedShot"
:project-id="projectId" :project-id="projectId"
:shot-id="selectedShot.id" :shot-id="selectedShot.id"
:initial-shot="selectedShot" :initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot" @edit="editShot"
@delete="deleteShot" @delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask" @select-task="handleSelectTask"
/> />
</SheetContent> </SheetContent>
@@ -316,6 +333,7 @@ import ShotCard from './ShotCard.vue'
import ShotForm from './ShotForm.vue' import ShotForm from './ShotForm.vue'
import BulkShotForm from './BulkShotForm.vue' import BulkShotForm from './BulkShotForm.vue'
import ShotDetailPanel from './ShotDetailPanel.vue' import ShotDetailPanel from './ShotDetailPanel.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import ShotsDataTable from './ShotsDataTable.vue' import ShotsDataTable from './ShotsDataTable.vue'
import ShotTableToolbar from './ShotTableToolbar.vue' import ShotTableToolbar from './ShotTableToolbar.vue'
import { createShotColumns, type ShotColumnMeta } from './columns' import { createShotColumns, type ShotColumnMeta } from './columns'
@@ -845,15 +863,19 @@ const clearSearch = () => {
loadShots() loadShots()
} }
const handleCreateTask = () => { const selectedTaskId = ref<number | null>(null)
// TODO: Navigate to task creation for this shot const selectedTaskTab = ref<string>('infos')
console.log('Create task for shot:', selectedShot.value?.name)
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id
selectedTaskTab.value = tab || 'infos'
} }
const handleSelectTask = (task: any) => { // Reset the task sub-panel whenever the shot selection changes (including close)
// TODO: Navigate to task detail view watch(selectedShot, () => {
console.log('View task:', task.name) selectedTaskId.value = null
} selectedTaskTab.value = 'infos'
})
const formatStatus = (status: ShotStatus) => { const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word => return status.split('_').map(word =>
@@ -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 deletionInfo.value = null
loadError.value = null loadError.value = null
} }
}) }, { immediate: true })
const handleDelete = async () => { const handleDelete = async () => {
if (!isConfirmed.value) return if (!isConfirmed.value) return
+115 -24
View File
@@ -131,15 +131,58 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3> <h3 class="text-sm font-semibold">Tasks</h3>
<Button <div class="flex items-center gap-1">
v-if="canCreateTask" <Popover v-if="canCreateTask">
size="sm" <PopoverTrigger as-child>
variant="outline" <Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
@click="$emit('create-task')"
>
<Plus class="h-3 w-3 mr-1" /> <Plus class="h-3 w-3 mr-1" />
Add Task Add Task
</Button> </Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
All task types already added
</div>
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="type in availableTaskTypes"
:key="type"
variant="ghost"
size="sm"
class="justify-start"
:disabled="isCreatingTask"
@click="handleAddTask(type)"
>
{{ formatTaskType(type) }}
</Button>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'submissions')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div> </div>
<!-- No Tasks --> <!-- No Tasks -->
<div v-if="tasks.length === 0" class="text-center py-8"> <div v-if="tasks.length === 0" class="text-center py-8">
@@ -158,7 +201,7 @@
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0" class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
@click="$emit('select-task', task)" @click="$emit('select-task', task, 'infos')"
> >
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div> <div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
@@ -178,15 +221,29 @@
<TabsContent value="notes" class="flex-1 p-6 space-y-4"> <TabsContent value="notes" class="flex-1 p-6 space-y-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Production Notes</h3> <h3 class="text-sm font-semibold">Production Notes</h3>
<Button <Popover v-if="canCreateNote">
v-if="canCreateNote" <PopoverTrigger as-child>
size="sm" <Button size="sm" variant="outline" :disabled="tasks.length === 0">
variant="outline"
@click="$emit('create-note')"
>
<Plus class="h-3 w-3 mr-1" /> <Plus class="h-3 w-3 mr-1" />
Add Note Add Note
</Button> </Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div> </div>
<div class="text-center py-8"> <div class="text-center py-8">
@@ -222,15 +279,29 @@
<TabsContent value="references" class="flex-1 p-6"> <TabsContent value="references" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3> <h3 class="text-sm font-semibold">Reference Files</h3>
<Button <Popover v-if="canUploadReferences">
v-if="canUploadReferences" <PopoverTrigger as-child>
size="sm" <Button size="sm" variant="outline" :disabled="tasks.length === 0">
variant="outline"
@click="$emit('upload-reference')"
>
<Plus class="h-3 w-3 mr-1" /> <Plus class="h-3 w-3 mr-1" />
Upload Reference Upload Reference
</Button> </Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'attachments')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div> </div>
<div class="text-center py-8"> <div class="text-center py-8">
@@ -280,14 +351,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot' import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
// Use TaskStatusInfo from shot service instead of local Task interface // Use TaskStatusInfo from shot service instead of local Task interface
@@ -302,16 +375,14 @@ interface Props {
projectId: number projectId: number
shotId: number shotId: number
initialShot?: Shot initialShot?: Shot
allTaskTypes: string[]
} }
interface Emits { interface Emits {
(e: 'edit', shot: Shot): void (e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void (e: 'delete', shot: Shot): void
(e: 'create-task'): void (e: 'select-task', task: Task, tab?: string): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'link-asset'): void (e: 'link-asset'): void
(e: 'upload-reference'): void
(e: 'edit-design'): void (e: 'edit-design'): void
(e: 'close'): void (e: 'close'): void
} }
@@ -326,6 +397,7 @@ const shot = ref<Shot | null>(null)
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isCreatingTask = ref(false)
// Computed properties // Computed properties
const frameCount = computed(() => { const frameCount = computed(() => {
@@ -383,6 +455,11 @@ const canEditDesign = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
}) })
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
})
// Methods // Methods
const loadShotDetails = async () => { const loadShotDetails = async () => {
try { try {
@@ -415,6 +492,20 @@ const loadTasks = () => {
} }
} }
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createShotTask(props.shotId, taskType)
// Bypass initialShot (which would just return the stale cached object) to get the new task
shot.value = await shotService.getShot(props.shotId)
loadTasks()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatStatus = (status: ShotStatus) => { const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word => return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1) word.charAt(0).toUpperCase() + word.slice(1)
+34 -3
View File
@@ -93,6 +93,23 @@
</div> </div>
</div> </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> </div>
</template> </template>
@@ -102,6 +119,16 @@ import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 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 { taskService, type ProductionNote } from '@/services/task'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -121,6 +148,7 @@ const authStore = useAuthStore()
const editing = ref(false) const editing = ref(false)
const editContent = ref('') const editContent = ref('')
const showDeleteDialog = ref(false)
const canEdit = computed(() => { const canEdit = computed(() => {
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
@@ -174,9 +202,11 @@ async function handleSave() {
} }
} }
async function handleDelete() { function handleDelete() {
if (!confirm('Are you sure you want to delete this note?')) return showDeleteDialog.value = true
}
async function confirmDelete() {
try { try {
await taskService.deleteTaskNote(props.taskId, props.note.id) await taskService.deleteTaskNote(props.taskId, props.note.id)
emit('noteUpdated') emit('noteUpdated')
@@ -185,12 +215,13 @@ async function handleDelete() {
description: 'Note deleted successfully' description: 'Note deleted successfully'
}) })
} catch (error: any) { } catch (error: any) {
console.error('Error deleting note:', error)
toast({ toast({
title: 'Error', title: 'Error',
description: error.response?.data?.detail || 'Failed to delete note', description: error.response?.data?.detail || 'Failed to delete note',
variant: 'destructive' variant: 'destructive'
}) })
} finally {
showDeleteDialog.value = false
} }
} }
@@ -104,6 +104,23 @@
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
</template> </template>
@@ -125,6 +142,16 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import AttachmentCard from './AttachmentCard.vue' import AttachmentCard from './AttachmentCard.vue'
import { taskService, type TaskAttachment } from '@/services/task' import { taskService, type TaskAttachment } from '@/services/task'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -148,6 +175,8 @@ const filterType = ref('all')
const viewerOpen = ref(false) const viewerOpen = ref(false)
const selectedAttachment = ref<TaskAttachment | null>(null) const selectedAttachment = ref<TaskAttachment | null>(null)
const mediaBlobUrl = ref<string | null>(null) const mediaBlobUrl = ref<string | null>(null)
const showDeleteDialog = ref(false)
const attachmentToDelete = ref<number | null>(null)
const attachmentTypes = [ const attachmentTypes = [
{ value: 'all', label: 'All' }, { value: 'all', label: 'All' },
@@ -197,23 +226,30 @@ async function handleFileSelect(event: Event) {
} }
} }
async function handleDelete(attachmentId: number) { function handleDelete(attachmentId: number) {
if (!confirm('Are you sure you want to delete this attachment?')) return attachmentToDelete.value = attachmentId
showDeleteDialog.value = true
}
async function confirmDeleteAttachment() {
if (attachmentToDelete.value === null) return
try { try {
await taskService.deleteTaskAttachment(props.taskId, attachmentId) await taskService.deleteTaskAttachment(props.taskId, attachmentToDelete.value)
emit('attachmentsUpdated') emit('attachmentsUpdated')
toast({ toast({
title: 'Success', title: 'Success',
description: 'Attachment deleted successfully' description: 'Attachment deleted successfully'
}) })
} catch (error: any) { } catch (error: any) {
console.error('Error deleting attachment:', error)
toast({ toast({
title: 'Error', title: 'Error',
description: error.response?.data?.detail || 'Failed to delete attachment', description: error.response?.data?.detail || 'Failed to delete attachment',
variant: 'destructive' variant: 'destructive'
}) })
} finally {
showDeleteDialog.value = false
attachmentToDelete.value = null
} }
} }
@@ -26,7 +26,7 @@
</div> </div>
<!-- Tabbed Content --> <!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0"> <Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
<!-- Tabs List (Fixed) --> <!-- Tabs List (Fixed) -->
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b"> <TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger> <TabsTrigger value="infos">Infos</TabsTrigger>
@@ -315,6 +315,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
initialTab?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -134,6 +134,11 @@ const columns = createColumns({
onBulkStatusChange: handleBulkStatusChange, onBulkStatusChange: handleBulkStatusChange,
onStatusUpdated: handleStatusUpdated, onStatusUpdated: handleStatusUpdated,
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length, getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
// Both actions open the task detail panel (which already has a working Reassign flow).
// row-click is a no-op in TaskBrowser.vue (single click is reserved for selection) - only
// row-double-click actually opens the panel, so reuse that instead.
onViewDetails: (task: Task) => emit('row-double-click', task),
onReassign: (task: Task) => emit('row-double-click', task),
}) })
// TanStack Table configuration // TanStack Table configuration
+88 -1
View File
@@ -1,6 +1,6 @@
import { h, ref } from 'vue' import { h, ref } from 'vue'
import type { ColumnDef } from '@tanstack/vue-table' import type { ColumnDef } from '@tanstack/vue-table'
import { ArrowUpDown, Film, Package, ChevronDown } from 'lucide-vue-next' import { ArrowUpDown, Film, Package, ChevronDown, MoreHorizontal, Eye, UserCog } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -9,6 +9,12 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue' import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { type Task } from '@/services/task' import { type Task } from '@/services/task'
@@ -27,6 +33,8 @@ interface ColumnCallbacks {
onBulkStatusChange?: (status: TaskStatus) => void onBulkStatusChange?: (status: TaskStatus) => void
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
getSelectedCount?: () => number getSelectedCount?: () => number
onViewDetails?: (task: Task) => void
onReassign?: (task: Task) => void
} }
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => { export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
@@ -308,5 +316,84 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at'))) return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at')))
}, },
}, },
{
id: 'actions',
cell: ({ row }) => {
const task = row.original
return h(
DropdownMenu,
{},
{
default: () => [
h(
DropdownMenuTrigger,
{ asChild: true },
{
default: () =>
h(
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
},
},
{
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
}
),
}
),
h(
DropdownMenuContent,
{ align: 'end' },
{
default: () => [
h(
DropdownMenuItem,
{
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
callbacks?.onViewDetails?.(task)
},
},
{
default: () => [
h(Eye, { class: 'h-4 w-4 mr-2' }),
'View Details',
],
}
),
h(
DropdownMenuItem,
{
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
callbacks?.onReassign?.(task)
},
},
{
default: () => [
h(UserCog, { class: 'h-4 w-4 mr-2' }),
'Reassign',
],
}
),
],
}
),
],
}
)
},
},
] ]
} }
+6
View File
@@ -83,6 +83,12 @@ const routes: RouteRecordRaw[] = [
name: 'ProjectSettings', name: 'ProjectSettings',
component: () => import('@/views/ProjectSettingsView.vue'), component: () => import('@/views/ProjectSettingsView.vue'),
meta: { tab: 'settings', tabLabel: 'Settings' } meta: { tab: 'settings', tabLabel: 'Settings' }
},
{
path: 'technical-specs',
name: 'ProjectTechnicalSpecs',
component: () => import('@/views/ProjectTechnicalSpecsView.vue'),
meta: { tab: 'technical-specs', tabLabel: 'Technical Specs' }
} }
] ]
}, },
-10
View File
@@ -150,18 +150,8 @@ class AssetService {
} }
const url = `/assets/?${params}` const url = `/assets/?${params}`
console.log('AssetService - Fetching assets from:', url)
console.log('AssetService - Project ID:', projectId)
try {
const response = await apiClient.get(url) 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 return response.data
} catch (error) {
console.error('AssetService - Error fetching assets:', error)
throw error
}
} }
async getAsset(assetId: number): Promise<Asset> { async getAsset(assetId: number): Promise<Asset> {
+15
View File
@@ -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
}
}
+20
View File
@@ -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
}
}
-9
View File
@@ -86,17 +86,8 @@ export const userService = {
is_approved?: boolean is_approved?: boolean
is_admin?: boolean is_admin?: boolean
}): Promise<User> { }): Promise<User> {
console.log('userService.editUser called with:', { userId, userData })
try {
const response = await apiClient.put(`/users/${userId}`, userData) const response = await apiClient.put(`/users/${userId}`, userData)
return normalizeUser(response.data) 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
}
}, },
async resetUserPassword(userId: number, newPassword: string): Promise<{ message: string; user_id: number }> { async resetUserPassword(userId: number, newPassword: string): Promise<{ message: string; user_id: number }> {
+84 -45
View File
@@ -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 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="h-2 w-2 rounded-full bg-orange-500"></div>
<div class="flex-1"> <div class="flex-1">
<p class="font-medium">{{ submission.task_name }}</p> <p class="font-medium">{{ submission.file_name }}</p>
<p class="text-sm text-muted-foreground">by {{ submission.artist_name }}</p> <p class="text-sm text-muted-foreground">by {{ submission.user_first_name }} {{ submission.user_last_name }}</p>
</div> </div>
<div class="text-sm text-muted-foreground">{{ formatSubmissionTime(submission.submitted_at) }}</div> <div class="text-sm text-muted-foreground">{{ formatSubmissionTime(submission.submitted_at) }}</div>
</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 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="h-2 w-2 rounded-full bg-green-500"></div>
<div class="flex-1"> <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> <p class="text-sm text-muted-foreground">{{ activity.description }}</p>
</div> </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> </div>
</div> </div>
@@ -400,10 +400,6 @@
<Key class="h-4 w-4 mr-2" /> <Key class="h-4 w-4 mr-2" />
Manage API Keys Manage API Keys
</Button> </Button>
<Button variant="outline" class="justify-start" @click="navigateTo('/developer/docs')">
<FileText class="h-4 w-4 mr-2" />
API Documentation
</Button>
</template> </template>
<!-- Admin Actions (when user has admin permission) --> <!-- Admin Actions (when user has admin permission) -->
@@ -429,22 +425,32 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
CheckSquare, Clock, CheckCircle, FolderOpen, User, AlertTriangle, Users, CheckSquare, Clock, CheckCircle, FolderOpen, User, AlertTriangle, Users,
TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield, TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield
FileText
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/auth' 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 authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const projectsStore = useProjectsStore()
const tasksStore = useTasksStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const userRole = computed(() => authStore.userRole) const userRole = computed(() => authStore.userRole)
const isAdmin = computed(() => authStore.isAdmin) const isAdmin = computed(() => authStore.isAdmin)
// Dashboard stats (mock data for now - would be fetched from API)
const dashboardStats = ref({ const dashboardStats = ref({
// Artist stats // Artist stats
// TODO(backend): "my tasks/projects across all projects" has no dedicated endpoint yet
activeTasks: 0, activeTasks: 0,
pendingReviews: 0, pendingReviews: 0,
completedTasks: 0, completedTasks: 0,
@@ -453,15 +459,19 @@ const dashboardStats = ref({
// Coordinator stats // Coordinator stats
totalProjects: 0, totalProjects: 0,
overdueTasks: 0, overdueTasks: 0,
// TODO(backend): no endpoint for role-filtered active-artist counts
activeArtists: 0, activeArtists: 0,
// TODO(backend): Project has no completion_rate field
completionRate: 0, completionRate: 0,
// Director stats // Director stats
// TODO(backend): no "approved/retakes today" aggregate endpoint
approvedToday: 0, approvedToday: 0,
retakesRequested: 0, retakesRequested: 0,
// Developer stats // Developer stats
apiKeys: 0, apiKeys: 0,
// TODO(backend): /developer/stats' api_usage_count is all-time, not "today"
apiCallsToday: 0, apiCallsToday: 0,
totalTasks: 0, totalTasks: 0,
@@ -473,23 +483,12 @@ const dashboardStats = ref({
// Role-specific data // Role-specific data
const recentTasks = ref([]) const recentTasks = ref([])
// TODO(backend): project completion_rate isn't available yet, so this list is left empty for now
const projectOverview = ref([]) 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 apiActivity = ref([])
const systemActivity = ref([ const systemActivity = ref<ActivityRecord[]>([])
{
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()
}
])
// Methods // Methods
const formatRole = (role?: string) => { const formatRole = (role?: string) => {
@@ -535,6 +534,13 @@ const formatTime = (timestamp: string) => {
return `${diffDays}d ago` 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) => { const getTaskStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'not_started': return 'bg-gray-500' case 'not_started': return 'bg-gray-500'
@@ -562,26 +568,59 @@ const navigateTo = (path: string) => {
} }
const loadDashboardData = async () => { const loadDashboardData = async () => {
// Mock data loading - in real implementation, this would fetch from API based on user role
try { try {
// Load role-specific stats const needsProjects = userRole.value === 'coordinator' || userRole.value === 'director' || isAdmin.value
dashboardStats.value = { const tasks: Promise<unknown>[] = []
activeTasks: 5,
pendingReviews: 2, if (needsProjects) {
completedTasks: 28, tasks.push(projectsStore.fetchProjects())
myProjects: 3, }
totalProjects: 8, if (userRole.value === 'coordinator') {
overdueTasks: 3, tasks.push(tasksStore.fetchTasks())
activeArtists: 12, }
completionRate: 75, if (userRole.value === 'director') {
approvedToday: 8, tasks.push(
retakesRequested: 2, reviewService.getPendingReviews().then(submissions => {
apiKeys: 3, pendingReviews.value = submissions
apiCallsToday: 156, })
totalTasks: 245, )
totalUsers: 25, }
pendingApprovals: 2, if (userRole.value === 'developer') {
activeProjects: 5 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) { } catch (error) {
console.error('Failed to load dashboard data:', error) console.error('Failed to load dashboard data:', error)
-18
View File
@@ -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>
-25
View File
@@ -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>
+3 -2
View File
@@ -147,8 +147,9 @@ const manageTechnicalSpecs = () => {
} }
const manageMembers = () => { const manageMembers = () => {
// TODO: Implement member management if (projectId.value) {
console.log('Manage members') router.push(`/projects/${projectId.value}/settings`)
}
} }
const getStatusVariant = (status: string) => { const getStatusVariant = (status: string) => {
+3 -3
View File
@@ -346,9 +346,9 @@
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ProjectMembersManager <ProjectMemberManagement
v-if="selectedProject && showMembersDialog" v-if="selectedProject && showMembersDialog"
:project="selectedProject" :project-id="selectedProject.id"
@close="showMembersDialog = false" @close="showMembersDialog = false"
/> />
</DialogContent> </DialogContent>
@@ -407,7 +407,7 @@ import {
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ProjectMembersManager from '@/components/project/ProjectMembersManager.vue' import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
import type { Project } from '@/stores/projects' import type { Project } from '@/stores/projects'
import { apiClient } from '@/services/api' import { apiClient } from '@/services/api'
-5
View File
@@ -257,15 +257,11 @@ const handleEditUser = (user: User) => {
const handleEditUserSubmit = async (userId: number, data: UserEditData) => { const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
try { try {
isEditingUser.value = true isEditingUser.value = true
console.log('Editing user with data:', data)
await userStore.editUser(userId, data) await userStore.editUser(userId, data)
showEditDialog.value = false showEditDialog.value = false
showSuccessMessage('User updated successfully') showSuccessMessage('User updated successfully')
await refreshData() await refreshData()
} catch (err: any) { } catch (err: any) {
console.error('Failed to edit user:', err)
console.error('Error response:', err.response?.data)
// Handle FastAPI validation errors (422) // Handle FastAPI validation errors (422)
let errorMessage = 'Failed to update user' let errorMessage = 'Failed to update user'
if (err.response?.data?.detail) { if (err.response?.data?.detail) {
@@ -276,7 +272,6 @@ const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
return `${field}: ${e.msg}` return `${field}: ${e.msg}`
}).join(', ') }).join(', ')
errorMessage = errors errorMessage = errors
console.error('Validation errors:', err.response.data.detail)
} else { } else {
errorMessage = err.response.data.detail errorMessage = err.response.data.detail
} }
@@ -915,17 +915,11 @@ const loadDeletedItems = async () => {
const projectId = selectedProjectId.value && selectedProjectId.value !== 'all' ? parseInt(selectedProjectId.value) : undefined 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([ const [shots, assets] = await Promise.all([
recoveryService.getDeletedShots(projectId), recoveryService.getDeletedShots(projectId),
recoveryService.getDeletedAssets(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 deletedShots.value = shots
deletedAssets.value = assets deletedAssets.value = assets
@@ -933,8 +927,6 @@ const loadDeletedItems = async () => {
selectedItems.value = [] selectedItems.value = []
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to load recovery data' 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 { } finally {
isLoading.value = false isLoading.value = false
} }
-14
View File
@@ -43,16 +43,6 @@
Login Login
</Button> </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"> <div v-if="error" class="text-sm text-destructive text-center">
{{ error }} {{ error }}
</div> </div>
@@ -103,8 +93,4 @@ const handleSubmit = async () => {
} }
} }
const handleGoogleLogin = () => {
// TODO: Implement Google OAuth login
console.log('Google login not implemented yet')
}
</script> </script>
-14
View File
@@ -73,16 +73,6 @@
Create account Create account
</Button> </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"> <div v-if="error" class="text-sm text-destructive text-center">
{{ error }} {{ error }}
</div> </div>
@@ -217,8 +207,4 @@ const handleSubmit = async () => {
} }
} }
const handleGoogleSignup = () => {
// TODO: Implement Google OAuth signup
console.log('Google signup not implemented yet')
}
</script> </script>
@@ -11,8 +11,6 @@
:icon="Key" :icon="Key"
title="No API keys" title="No API keys"
description="Create API keys to integrate external applications with the VFX system." description="Create API keys to integrate external applications with the VFX system."
action-text="Create API Key"
@action="() => {}"
/> />
</div> </div>
</div> </div>
@@ -37,19 +37,8 @@ const assetsStore = useAssetsStore()
// Computed properties // Computed properties
const projectId = computed(() => { const projectId = computed(() => {
const id = route.params.projectId const id = route.params.projectId
const parsedId = typeof id === 'string' ? parseInt(id) : Array.isArray(id) ? parseInt(id[0]) : 0 return 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
}) })
const totalAssets = computed(() => { const totalAssets = computed(() => assetsStore.assets.length)
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)
</script> </script>
@@ -105,29 +105,22 @@
</div> </div>
<!-- Recent Activity --> <!-- Recent Activity -->
<Card class="mt-6"> <div class="mt-6" v-if="project">
<CardHeader> <ActivityFeed :project-id="project.id" title="Recent Activity" />
<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> </div>
</CardContent>
</Card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import TechnicalSpecsSummary from '@/components/project/TechnicalSpecsSummary.vue' import TechnicalSpecsSummary from '@/components/project/TechnicalSpecsSummary.vue'
import ActivityFeed from '@/components/activity/ActivityFeed.vue'
const route = useRoute() const route = useRoute()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
+203
View File
@@ -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.
+98
View File
@@ -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`.*