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
+38 -37
View File
@@ -161,26 +161,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
@@ -251,20 +239,11 @@ 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 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 +252,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 +366,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
@@ -563,9 +547,20 @@ 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
showDeleteDialog.value = true; 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) => { const viewAssetTasks = (asset: Asset) => {
@@ -619,15 +614,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({
@@ -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,59 +71,56 @@
</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" /> <AlertTitle class="text-orange-800">
<span class="font-medium 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 </AlertTitle>
</span> <AlertDescription class="text-orange-700">
</div> <p class="mb-3">
The following users have work associated with this asset that will be marked as deleted:
</p>
<p class="text-sm text-orange-700 mb-3"> <div class="max-h-40 overflow-y-auto space-y-2">
The following users have work associated with this asset that will be marked as deleted: <div
</p> v-for="user in deletionInfo.affected_users"
:key="user.id"
<div class="max-h-40 overflow-y-auto space-y-2"> class="flex items-center justify-between p-2 bg-white rounded border"
<div >
v-for="user in deletionInfo.affected_users" <div class="flex-1">
:key="user.id" <div class="font-medium text-sm">{{ user.name }}</div>
class="flex items-center justify-between p-2 bg-white rounded border" <div class="text-xs text-muted-foreground">{{ user.email }} {{ user.role }}</div>
> </div>
<div class="flex-1"> <div class="text-xs text-muted-foreground text-right">
<div class="font-medium text-sm">{{ user.name }}</div> <div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div> <div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div>
</div> <div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
<div class="text-xs text-muted-foreground text-right"> <div v-if="user.last_activity_date" class="mt-1">
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div> Last active: {{ formatDate(user.last_activity_date) }}
<div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div> </div>
<div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
<div v-if="user.last_activity_date" class="mt-1">
Last active: {{ formatDate(user.last_activity_date) }}
</div> </div>
</div> </div>
</div> </div>
</div> </AlertDescription>
</div> </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" /> <AlertDescription class="text-green-800">
<span class="text-sm text-green-800">No users will be affected by this deletion.</span> No users will be affected by this deletion.
</div> </AlertDescription>
</div> </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" /> <AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
<span class="font-medium text-blue-800">Data Preservation</span> <AlertDescription class="text-blue-700">
</div>
<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
@@ -12,12 +12,7 @@
> >
<SelectValue <SelectValue
:model-value="currentStatusId" :model-value="currentStatusId"
> />
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
<!-- console.log(currentStatusObject) -->
<!-- currentStatusObject -->
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
@@ -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.`)
} }
@@ -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');
} }
}; };
@@ -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({
@@ -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
+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
} }
} }
+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' }
} }
] ]
}, },
+2 -12
View File
@@ -150,18 +150,8 @@ class AssetService {
} }
const url = `/assets/?${params}` const url = `/assets/?${params}`
console.log('AssetService - Fetching assets from:', url) const response = await apiClient.get(url)
console.log('AssetService - Project ID:', projectId) return response.data
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> { 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
}
}
+2 -11
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 }) const response = await apiClient.put(`/users/${userId}`, userData)
try { return normalizeUser(response.data)
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 }> { 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)
-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> </div>
</CardHeader>
<CardContent>
<div class="text-center py-8 text-muted-foreground">
<Activity class="h-8 w-8 mx-auto mb-2" />
<p>Activity feed coming soon</p>
</div>
</CardContent>
</Card>
</div> </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.
+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`.*