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>
This commit is contained in:
2026-07-17 04:47:26 +08:00
parent c63a71883b
commit cd2efe3587
25 changed files with 639 additions and 331 deletions
+37 -36
View File
@@ -161,26 +161,14 @@
</Dialog>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
action cannot be undone and will remove all associated tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
@click="handleDeleteAsset"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Asset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AssetDeleteConfirmDialog
v-if="deletionInfo && assetToDelete"
:open="showDeleteDialog"
:asset-id="assetToDelete.id"
:asset-name="deletionInfo.asset_name"
@update:open="showDeleteDialog = $event"
@confirm-delete="handleDeleteAsset"
/>
</div>
<!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition
@@ -251,20 +239,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
import AssetsDataTable from "./AssetsDataTable.vue";
import AssetTableToolbar from "./AssetTableToolbar.vue";
import { createAssetColumns, type AssetColumnMeta } from "./columns";
@@ -273,11 +252,13 @@ import { useAuthStore } from "@/stores/auth";
import { useTaskStatusesStore } from "@/stores/taskStatuses";
import { useDetailPanel } from "@/composables/useDetailPanel";
import {
assetService,
AssetCategory,
TaskStatus,
type Asset,
type AssetCreate,
type AssetUpdate,
type AssetDeletionInfo,
} from "@/services/asset";
import { useToast } from "@/components/ui/toast/use-toast";
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
@@ -385,6 +366,9 @@ const showDeleteDialog = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const assetToDelete = ref<Asset | null>(null);
const deletionInfo = ref<AssetDeletionInfo | null>(null);
const taskStatusFilter = ref('')
// Thumbnail display state - with session storage
@@ -563,9 +547,20 @@ const editAsset = (asset: Asset) => {
showEditDialog.value = true;
};
const deleteAsset = (asset: Asset) => {
selectedAsset.value = asset;
const deleteAsset = async (asset: Asset) => {
// Don't set selectedAsset here as it opens the detail panel
assetToDelete.value = asset;
try {
deletionInfo.value = await assetService.getAssetDeletionInfo(asset.id);
showDeleteDialog.value = true;
} catch (err) {
toast({
title: "Failed to get asset information",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
}
};
const viewAssetTasks = (asset: Asset) => {
@@ -619,15 +614,21 @@ const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
};
const handleDeleteAsset = async () => {
if (!selectedAsset.value) return;
if (!assetToDelete.value) return;
try {
await assetsStore.deleteAsset(selectedAsset.value.id);
await assetsStore.deleteAsset(assetToDelete.value.id);
showDeleteDialog.value = false;
selectedAsset.value = null;
assetToDelete.value = null;
const taskCount = deletionInfo.value?.task_count || 0;
deletionInfo.value = null;
toast({
title: "Asset deleted",
description: "Asset has been deleted successfully.",
description: taskCount > 0
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
: "Asset has been deleted successfully.",
});
} catch (err) {
toast({
@@ -21,13 +21,11 @@
</div>
<!-- Error State -->
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="h-4 w-4 text-destructive" />
<span class="font-medium text-destructive">Failed to load deletion information</span>
</div>
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
</div>
<Alert v-else-if="loadError" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertTitle>Failed to load deletion information</AlertTitle>
<AlertDescription>{{ loadError }}</AlertDescription>
</Alert>
<!-- Deletion Information -->
<div v-else-if="deletionInfo" class="space-y-4">
@@ -73,15 +71,13 @@
</div>
<!-- Affected Users -->
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4">
<div class="flex items-center gap-2 mb-3">
<Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
<Users class="h-4 w-4 text-orange-600" />
<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
</span>
</div>
<p class="text-sm text-orange-700 mb-3">
</AlertTitle>
<AlertDescription class="text-orange-700">
<p class="mb-3">
The following users have work associated with this asset that will be marked as deleted:
</p>
@@ -93,7 +89,7 @@
>
<div class="flex-1">
<div class="font-medium text-sm">{{ user.name }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} {{ user.role }}</div>
</div>
<div class="text-xs text-muted-foreground text-right">
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
@@ -105,27 +101,26 @@
</div>
</div>
</div>
</div>
</AlertDescription>
</Alert>
<!-- No affected users -->
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4">
<div class="flex items-center gap-2">
<Alert v-else variant="default" class="border-green-200 bg-green-50">
<CheckCircle class="h-4 w-4 text-green-600" />
<span class="text-sm text-green-800">No users will be affected by this deletion.</span>
</div>
</div>
<AlertDescription class="text-green-800">
No users will be affected by this deletion.
</AlertDescription>
</Alert>
<!-- Data Preservation Notice -->
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center gap-2 mb-2">
<Alert variant="default" class="border-blue-200 bg-blue-50">
<Shield class="h-4 w-4 text-blue-600" />
<span class="font-medium text-blue-800">Data Preservation</span>
</div>
<p class="text-sm text-blue-700">
<AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
<AlertDescription class="text-blue-700">
All data will be preserved in the database and can be recovered by administrators.
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
</p>
</div>
</AlertDescription>
</Alert>
<!-- Confirmation input -->
<div class="space-y-2">
@@ -189,6 +184,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/ui/alert'
import { assetService, type AssetDeletionInfo } from '@/services/asset'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -275,7 +275,7 @@ watch(() => props.open, (newOpen) => {
deletionInfo.value = null
loadError.value = null
}
})
}, { immediate: true })
const handleDelete = async () => {
if (!isConfirmed.value) return
@@ -12,12 +12,7 @@
>
<SelectValue
:model-value="currentStatusId"
>
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
<!-- console.log(currentStatusObject) -->
<!-- currentStatusObject -->
</SelectValue>
/>
</SelectTrigger>
<SelectContent>
<SelectItem
@@ -119,7 +119,6 @@ import {
Key,
Database,
BarChart3,
FileText,
RotateCcw,
} from 'lucide-vue-next'
@@ -183,8 +182,7 @@ const developerItems = computed(() => [
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
{ title: 'All Projects', url: '/developer/projects', icon: Database },
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 }
])
// Mock recent projects - this would come from a store in real implementation
+26 -37
View File
@@ -65,26 +65,11 @@
<User class="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
<Palette class="size-4" />
Preferences
</DropdownMenuItem>
<DropdownMenuItem @click="toggleNotifications">
<Bell class="size-4" />
Notifications
<span class="ml-auto text-xs text-muted-foreground">
{{ notificationsEnabled ? 'On' : 'Off' }}
</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<!-- Help and support -->
<DropdownMenuGroup>
<DropdownMenuItem @click="navigateTo('/help')">
<HelpCircle class="size-4" />
Help & Support
</DropdownMenuItem>
<DropdownMenuItem @click="showKeyboardShortcuts">
<Keyboard class="size-4" />
Keyboard Shortcuts
@@ -99,6 +84,24 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog v-model:open="showShortcutsDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div class="grid gap-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Toggle sidebar</span>
<span class="font-mono">/Ctrl + B</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Quick search</span>
<span class="font-mono">/Ctrl + K</span>
</div>
</div>
</DialogContent>
</Dialog>
</SidebarMenuItem>
</SidebarMenu>
</template>
@@ -111,11 +114,8 @@ import {
LogOut,
User,
Settings,
Bell,
Key,
Users,
Palette,
HelpCircle,
Keyboard,
} from 'lucide-vue-next'
import {
@@ -132,6 +132,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
SidebarMenu,
SidebarMenuButton,
@@ -181,8 +187,7 @@ const showRoleFeatures = computed(() => {
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
})
// Notifications state (this would typically come from a notifications store)
const notificationsEnabled = ref(true)
const showShortcutsDialog = ref(false)
// Actions
const navigateTo = (path: string) => {
@@ -198,24 +203,8 @@ const handleLogout = async () => {
}
}
const toggleNotifications = () => {
notificationsEnabled.value = !notificationsEnabled.value
// In a real app, this would update user preferences
console.log('Notifications toggled:', notificationsEnabled.value)
}
const showKeyboardShortcuts = () => {
// In a real app, this would open a modal with keyboard shortcuts
console.log('Keyboard shortcuts modal would open here')
// For now, just show an alert with some common shortcuts
alert(`Keyboard Shortcuts:
⌘/Ctrl + B - Toggle sidebar
⌘/Ctrl + K - Quick search
⌘/Ctrl + , - Open preferences
⌘/Ctrl + / - Show help
More shortcuts available in the help documentation.`)
showShortcutsDialog.value = true
}
@@ -103,48 +103,30 @@ const tabs = computed<Tab[]>(() => [
const activeTab = computed(() => {
const currentPath = route.path;
// Debug logging
console.log('ProjectTabs - Current path:', currentPath);
console.log('ProjectTabs - Project ID:', props.projectId);
if (currentPath === `/projects/${props.projectId}`) {
console.log('ProjectTabs - Active tab: overview');
return "overview";
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
console.log('ProjectTabs - Active tab: shots');
return "shots";
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
console.log('ProjectTabs - Active tab: assets');
return "assets";
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
console.log('ProjectTabs - Active tab: tasks');
return "tasks";
} else if (
currentPath.startsWith(`/projects/${props.projectId}/settings`)
) {
console.log('ProjectTabs - Active tab: settings');
return "settings";
}
console.log('ProjectTabs - Active tab: overview (default)');
return "overview";
});
// Set active tab and navigate
const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId);
console.log('ProjectTabs - Setting active tab:', tabId);
console.log('ProjectTabs - Tab found:', tab);
console.log('ProjectTabs - Current route path:', route.path);
console.log('ProjectTabs - Target route:', tab?.route);
if (tab) {
console.log('ProjectTabs - Navigating to:', tab.route);
router.push(tab.route).catch(err => {
console.error('ProjectTabs - Navigation error:', err);
router.push(tab.route).catch(() => {
// Navigation aborted (e.g. duplicate route) — safe to ignore
});
} else {
console.log('ProjectTabs - Tab not found');
}
};
@@ -428,21 +428,13 @@ const handleDialogSave = async () => {
}
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
console.log('=== HANDLE DELETE ===')
console.log('Received taskType:', taskType)
console.log('Received category:', category)
taskTypeToDelete.value = taskType
categoryToDelete.value = category
deleteError.value = ''
isDeleteDialogOpen.value = true
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
}
const closeDeleteDialog = () => {
console.log('=== CLOSE DELETE DIALOG ===')
isDeleteDialogOpen.value = false
// Values will be cleared by @update:open handler
}
@@ -456,30 +448,17 @@ const confirmDelete = async () => {
isDeleting.value = true
deleteError.value = ''
console.log('=== DELETE DEBUG ===')
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
console.log('categoryToDelete.value:', categoryToDeleteLocal)
console.log('projectId:', props.projectId)
if (!taskTypeToDeleteLocal) {
console.error('ERROR: taskTypeToDelete is empty!')
deleteError.value = 'Task type name is missing. Please try again.'
isDeleting.value = false
return
}
console.log('Deleting task type:', {
projectId: props.projectId,
taskType: taskTypeToDeleteLocal,
category: categoryToDeleteLocal
})
const response = await customTaskTypeService.deleteCustomTaskType(
props.projectId,
taskTypeToDeleteLocal,
categoryToDeleteLocal
)
console.log('Delete task type response:', response)
taskTypes.value = response
toast({
@@ -258,7 +258,7 @@ watch(() => props.open, (newOpen) => {
deletionInfo.value = null
loadError.value = null
}
})
}, { immediate: true })
const handleDelete = async () => {
if (!isConfirmed.value) return
+34 -3
View File
@@ -93,6 +93,23 @@
</div>
</div>
</div>
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Note</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this note? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="confirmDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -102,6 +119,16 @@ import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { taskService, type ProductionNote } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -121,6 +148,7 @@ const authStore = useAuthStore()
const editing = ref(false)
const editContent = ref('')
const showDeleteDialog = ref(false)
const canEdit = computed(() => {
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
@@ -174,9 +202,11 @@ async function handleSave() {
}
}
async function handleDelete() {
if (!confirm('Are you sure you want to delete this note?')) return
function handleDelete() {
showDeleteDialog.value = true
}
async function confirmDelete() {
try {
await taskService.deleteTaskNote(props.taskId, props.note.id)
emit('noteUpdated')
@@ -185,12 +215,13 @@ async function handleDelete() {
description: 'Note deleted successfully'
})
} catch (error: any) {
console.error('Error deleting note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to delete note',
variant: 'destructive'
})
} finally {
showDeleteDialog.value = false
}
}
@@ -104,6 +104,23 @@
</div>
</DialogContent>
</Dialog>
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Attachment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this attachment? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="confirmDeleteAttachment" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -125,6 +142,16 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import AttachmentCard from './AttachmentCard.vue'
import { taskService, type TaskAttachment } from '@/services/task'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -148,6 +175,8 @@ const filterType = ref('all')
const viewerOpen = ref(false)
const selectedAttachment = ref<TaskAttachment | null>(null)
const mediaBlobUrl = ref<string | null>(null)
const showDeleteDialog = ref(false)
const attachmentToDelete = ref<number | null>(null)
const attachmentTypes = [
{ value: 'all', label: 'All' },
@@ -197,23 +226,30 @@ async function handleFileSelect(event: Event) {
}
}
async function handleDelete(attachmentId: number) {
if (!confirm('Are you sure you want to delete this attachment?')) return
function handleDelete(attachmentId: number) {
attachmentToDelete.value = attachmentId
showDeleteDialog.value = true
}
async function confirmDeleteAttachment() {
if (attachmentToDelete.value === null) return
try {
await taskService.deleteTaskAttachment(props.taskId, attachmentId)
await taskService.deleteTaskAttachment(props.taskId, attachmentToDelete.value)
emit('attachmentsUpdated')
toast({
title: 'Success',
description: 'Attachment deleted successfully'
})
} catch (error: any) {
console.error('Error deleting attachment:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to delete attachment',
variant: 'destructive'
})
} finally {
showDeleteDialog.value = false
attachmentToDelete.value = null
}
}
+6
View File
@@ -83,6 +83,12 @@ const routes: RouteRecordRaw[] = [
name: 'ProjectSettings',
component: () => import('@/views/ProjectSettingsView.vue'),
meta: { tab: 'settings', tabLabel: 'Settings' }
},
{
path: 'technical-specs',
name: 'ProjectTechnicalSpecs',
component: () => import('@/views/ProjectTechnicalSpecsView.vue'),
meta: { tab: 'technical-specs', tabLabel: 'Technical Specs' }
}
]
},
-10
View File
@@ -150,18 +150,8 @@ class AssetService {
}
const url = `/assets/?${params}`
console.log('AssetService - Fetching assets from:', url)
console.log('AssetService - Project ID:', projectId)
try {
const response = await apiClient.get(url)
console.log('AssetService - Assets response status:', response.status)
console.log('AssetService - Assets response data:', response.data)
return response.data
} catch (error) {
console.error('AssetService - Error fetching assets:', error)
throw error
}
}
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_admin?: boolean
}): Promise<User> {
console.log('userService.editUser called with:', { userId, userData })
try {
const response = await apiClient.put(`/users/${userId}`, userData)
return normalizeUser(response.data)
} catch (error: any) {
console.error('editUser API error:', error.response?.data)
if (error.response?.data?.detail && Array.isArray(error.response.data.detail)) {
console.error('Validation errors:', JSON.stringify(error.response.data.detail, null, 2))
}
throw error
}
},
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 class="h-2 w-2 rounded-full bg-orange-500"></div>
<div class="flex-1">
<p class="font-medium">{{ submission.task_name }}</p>
<p class="text-sm text-muted-foreground">by {{ submission.artist_name }}</p>
<p class="font-medium">{{ submission.file_name }}</p>
<p class="text-sm text-muted-foreground">by {{ submission.user_first_name }} {{ submission.user_last_name }}</p>
</div>
<div class="text-sm text-muted-foreground">{{ formatSubmissionTime(submission.submitted_at) }}</div>
</div>
@@ -341,10 +341,10 @@
<div v-for="activity in systemActivity" :key="activity.id" class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<div class="flex-1">
<p class="font-medium">{{ activity.action }}</p>
<p class="font-medium">{{ formatActivityType(activity.type) }}</p>
<p class="text-sm text-muted-foreground">{{ activity.description }}</p>
</div>
<div class="text-sm text-muted-foreground">{{ formatTime(activity.timestamp) }}</div>
<div class="text-sm text-muted-foreground">{{ formatTime(activity.created_at) }}</div>
</div>
</div>
</div>
@@ -400,10 +400,6 @@
<Key class="h-4 w-4 mr-2" />
Manage API Keys
</Button>
<Button variant="outline" class="justify-start" @click="navigateTo('/developer/docs')">
<FileText class="h-4 w-4 mr-2" />
API Documentation
</Button>
</template>
<!-- Admin Actions (when user has admin permission) -->
@@ -429,22 +425,32 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
CheckSquare, Clock, CheckCircle, FolderOpen, User, AlertTriangle, Users,
TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield,
FileText
TrendingUp, Eye, RotateCcw, Key, Activity, Database, UserCheck, Shield
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { useTasksStore } from '@/stores/tasks'
import { userService } from '@/services/user'
import { apiKeyService } from '@/services/apiKey'
import { developerService } from '@/services/developer'
import { reviewService, type PendingReviewSubmission } from '@/services/review'
import { getRecentActivities } from '@/services/activity'
import type { Activity as ActivityRecord } from '@/types/activity'
import { ActivityType } from '@/types/activity'
const authStore = useAuthStore()
const router = useRouter()
const projectsStore = useProjectsStore()
const tasksStore = useTasksStore()
const user = computed(() => authStore.user)
const userRole = computed(() => authStore.userRole)
const isAdmin = computed(() => authStore.isAdmin)
// Dashboard stats (mock data for now - would be fetched from API)
const dashboardStats = ref({
// Artist stats
// TODO(backend): "my tasks/projects across all projects" has no dedicated endpoint yet
activeTasks: 0,
pendingReviews: 0,
completedTasks: 0,
@@ -453,15 +459,19 @@ const dashboardStats = ref({
// Coordinator stats
totalProjects: 0,
overdueTasks: 0,
// TODO(backend): no endpoint for role-filtered active-artist counts
activeArtists: 0,
// TODO(backend): Project has no completion_rate field
completionRate: 0,
// Director stats
// TODO(backend): no "approved/retakes today" aggregate endpoint
approvedToday: 0,
retakesRequested: 0,
// Developer stats
apiKeys: 0,
// TODO(backend): /developer/stats' api_usage_count is all-time, not "today"
apiCallsToday: 0,
totalTasks: 0,
@@ -473,23 +483,12 @@ const dashboardStats = ref({
// Role-specific data
const recentTasks = ref([])
// TODO(backend): project completion_rate isn't available yet, so this list is left empty for now
const projectOverview = ref([])
const pendingReviews = ref([])
const pendingReviews = ref<PendingReviewSubmission[]>([])
// TODO(backend): needs a frontend wrapper for GET /developer/api-usage plus per-day filtering
const apiActivity = ref([])
const systemActivity = ref([
{
id: 1,
action: 'User Registration',
description: 'New user registered and awaiting approval',
timestamp: new Date().toISOString()
},
{
id: 2,
action: 'Project Created',
description: 'New project "Animation Series" created',
timestamp: new Date(Date.now() - 3600000).toISOString()
}
])
const systemActivity = ref<ActivityRecord[]>([])
// Methods
const formatRole = (role?: string) => {
@@ -535,6 +534,13 @@ const formatTime = (timestamp: string) => {
return `${diffDays}d ago`
}
const formatActivityType = (type: ActivityType) => {
return type
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const getTaskStatusColor = (status: string) => {
switch (status) {
case 'not_started': return 'bg-gray-500'
@@ -562,26 +568,59 @@ const navigateTo = (path: string) => {
}
const loadDashboardData = async () => {
// Mock data loading - in real implementation, this would fetch from API based on user role
try {
// Load role-specific stats
dashboardStats.value = {
activeTasks: 5,
pendingReviews: 2,
completedTasks: 28,
myProjects: 3,
totalProjects: 8,
overdueTasks: 3,
activeArtists: 12,
completionRate: 75,
approvedToday: 8,
retakesRequested: 2,
apiKeys: 3,
apiCallsToday: 156,
totalTasks: 245,
totalUsers: 25,
pendingApprovals: 2,
activeProjects: 5
const needsProjects = userRole.value === 'coordinator' || userRole.value === 'director' || isAdmin.value
const tasks: Promise<unknown>[] = []
if (needsProjects) {
tasks.push(projectsStore.fetchProjects())
}
if (userRole.value === 'coordinator') {
tasks.push(tasksStore.fetchTasks())
}
if (userRole.value === 'director') {
tasks.push(
reviewService.getPendingReviews().then(submissions => {
pendingReviews.value = submissions
})
)
}
if (userRole.value === 'developer') {
tasks.push(
apiKeyService.getAPIKeys().then(keys => {
dashboardStats.value.apiKeys = keys.length
}),
developerService.getStats().then(stats => {
dashboardStats.value.totalProjects = stats.total_projects
dashboardStats.value.totalTasks = stats.total_tasks
})
)
}
if (isAdmin.value) {
tasks.push(
userService.getUsers().then(users => {
dashboardStats.value.totalUsers = users.length
}),
userService.getPendingUsers().then(users => {
dashboardStats.value.pendingApprovals = users.length
}),
getRecentActivities().then(activities => {
systemActivity.value = activities
})
)
}
await Promise.all(tasks)
if (needsProjects) {
dashboardStats.value.totalProjects = projectsStore.projects.length
dashboardStats.value.activeProjects = projectsStore.projectsInProgress.length
}
if (userRole.value === 'coordinator') {
dashboardStats.value.overdueTasks = tasksStore.overdueTasks.length
}
if (userRole.value === 'director') {
dashboardStats.value.pendingReviews = pendingReviews.value.length
}
} catch (error) {
console.error('Failed to load dashboard data:', error)
-5
View File
@@ -257,15 +257,11 @@ const handleEditUser = (user: User) => {
const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
try {
isEditingUser.value = true
console.log('Editing user with data:', data)
await userStore.editUser(userId, data)
showEditDialog.value = false
showSuccessMessage('User updated successfully')
await refreshData()
} catch (err: any) {
console.error('Failed to edit user:', err)
console.error('Error response:', err.response?.data)
// Handle FastAPI validation errors (422)
let errorMessage = 'Failed to update user'
if (err.response?.data?.detail) {
@@ -276,7 +272,6 @@ const handleEditUserSubmit = async (userId: number, data: UserEditData) => {
return `${field}: ${e.msg}`
}).join(', ')
errorMessage = errors
console.error('Validation errors:', err.response.data.detail)
} else {
errorMessage = err.response.data.detail
}
@@ -915,17 +915,11 @@ const loadDeletedItems = async () => {
const projectId = selectedProjectId.value && selectedProjectId.value !== 'all' ? parseInt(selectedProjectId.value) : undefined
console.log('Loading recovery data with projectId:', projectId)
const [shots, assets] = await Promise.all([
recoveryService.getDeletedShots(projectId),
recoveryService.getDeletedAssets(projectId)
])
console.log('Loaded shots for recovery:', shots.length)
console.log('Loaded assets for recovery:', assets.length)
console.log('Shots data:', shots)
deletedShots.value = shots
deletedAssets.value = assets
@@ -933,8 +927,6 @@ const loadDeletedItems = async () => {
selectedItems.value = []
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to load recovery data'
console.error('Failed to load recovery data:', err)
console.error('Error details:', err.response)
} finally {
isLoading.value = false
}
-14
View File
@@ -43,16 +43,6 @@
Login
</Button>
<Button variant="outline" class="w-full" type="button" @click="handleGoogleLogin">
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Login with Google
</Button>
<div v-if="error" class="text-sm text-destructive text-center">
{{ error }}
</div>
@@ -103,8 +93,4 @@ const handleSubmit = async () => {
}
}
const handleGoogleLogin = () => {
// TODO: Implement Google OAuth login
console.log('Google login not implemented yet')
}
</script>
-14
View File
@@ -73,16 +73,6 @@
Create account
</Button>
<Button variant="outline" class="w-full" type="button" @click="handleGoogleSignup">
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign up with Google
</Button>
<div v-if="error" class="text-sm text-destructive text-center">
{{ error }}
</div>
@@ -217,8 +207,4 @@ const handleSubmit = async () => {
}
}
const handleGoogleSignup = () => {
// TODO: Implement Google OAuth signup
console.log('Google signup not implemented yet')
}
</script>
@@ -11,8 +11,6 @@
:icon="Key"
title="No API keys"
description="Create API keys to integrate external applications with the VFX system."
action-text="Create API Key"
@action="() => {}"
/>
</div>
</div>
@@ -37,19 +37,8 @@ const assetsStore = useAssetsStore()
// Computed properties
const projectId = computed(() => {
const id = route.params.projectId
const parsedId = typeof id === 'string' ? parseInt(id) : Array.isArray(id) ? parseInt(id[0]) : 0
console.log('ProjectAssetsView - Route params:', route.params)
console.log('ProjectAssetsView - Raw project ID:', id)
console.log('ProjectAssetsView - Parsed project ID:', parsedId)
return parsedId
return typeof id === 'string' ? parseInt(id) : Array.isArray(id) ? parseInt(id[0]) : 0
})
const totalAssets = computed(() => {
console.log('ProjectAssetsView - Total assets:', assetsStore.assets.length)
return assetsStore.assets.length
})
// Debug logging on mount
console.log('ProjectAssetsView - Component mounted')
console.log('ProjectAssetsView - Route params:', route.params)
const totalAssets = computed(() => assetsStore.assets.length)
</script>
@@ -105,29 +105,22 @@
</div>
<!-- Recent Activity -->
<Card class="mt-6">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div class="text-center py-8 text-muted-foreground">
<Activity class="h-8 w-8 mx-auto mb-2" />
<p>Activity feed coming soon</p>
<div class="mt-6" v-if="project">
<ActivityFeed :project-id="project.id" title="Recent Activity" />
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Camera, Package, Users, Activity } from 'lucide-vue-next'
import { Camera, Package, Users } from 'lucide-vue-next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { useProjectsStore } from '@/stores/projects'
import TechnicalSpecsSummary from '@/components/project/TechnicalSpecsSummary.vue'
import ActivityFeed from '@/components/activity/ActivityFeed.vue'
const route = useRoute()
const projectsStore = useProjectsStore()
+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.
+94
View File
@@ -0,0 +1,94 @@
# 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)
- [ ] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` (shot version at `components/shot/EditableTaskStatus.vue` is the reference)
- [ ] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` (shot version has `Lock`/`Unlock` toggle)
- [ ] Add row-actions ("…") menu to `components/task/columns.ts` (shot/asset already have one)
- [ ] Implement the six stubbed asset detail-panel actions in `components/asset/AssetBrowser.vue:571-574, 680-703`: create task, select task, create note, upload reference, publish version — or hide the affordances until built
- [ ] Implement shot detail-panel task stubs in `components/shot/ShotBrowser.vue:848-856`: create task, select task
- [ ] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component
- [ ] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` ("Manage Members" is currently a no-op)
## 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`.*