From 4f9deeb57adcc71c9b937afb0bf9425c6f93285a Mon Sep 17 00:00:00 2001 From: indigo Date: Sat, 18 Jul 2026 04:03:58 +0800 Subject: [PATCH] Phase 4: Architecture & tooling investment Fixes a real N+1 API-call bug (per-cell project-member fetches in shot/asset task-status cells, now a shared cached store), replaces a polling-based in-flight request de-dup with promise memoization, adds markRaw() around icon components in reactive state, and fixes a deep watcher that re-scanned the whole projects array just to trigger thumbnail loading. Introduces useAsyncAction and usePermission composables to cut duplicated store boilerplate and duplicated role/admin checks, applied only where the existing code was a clean match rather than forced onto everything. Extracts assets.ts's shared per-asset optimistic-update/rollback helper without merging the single and bulk API paths, which hit genuinely different endpoints. Adds toast-on-error for previously-silent secondary loads and fixes a dead try/catch in the router's auth-init guard along the way. Also fixes a thumbnail-loading regression introduced earlier in this same pass: the new ID-keyed watcher never fired on a repeat visit to the Projects page when the store already held the same project list, so thumbnails (local component state, reset per mount) silently stopped loading after the first visit. Replaced the watcher with a direct call after each fetch. Table virtualization, splitting the two largest view files, broad store caching, and moving domain types into types/ were scoped out as separate, higher-risk follow-ups (documented in frontend_tasks.md). Co-Authored-By: Claude Sonnet 5 --- .../components/asset/EditableTaskStatus.vue | 22 ++- .../components/episode/EpisodeDropdown.vue | 10 +- frontend/src/components/layout/AppSidebar.vue | 4 +- .../src/components/layout/ProjectSwitcher.vue | 7 +- .../project/TechnicalSpecsManager.vue | 10 +- .../project/TechnicalSpecsPanel.vue | 10 +- .../components/shot/EditableTaskStatus.vue | 24 +-- frontend/src/components/shot/ShotBrowser.vue | 28 +++- .../src/components/shot/ShotDetailPanel.vue | 22 +-- .../src/components/task/TaskDetailPanel.vue | 7 +- frontend/src/components/task/TaskList.vue | 8 +- frontend/src/composables/useAsyncAction.ts | 41 +++++ frontend/src/composables/usePermission.ts | 19 +++ frontend/src/router/index.ts | 16 +- frontend/src/stores/assets.ts | 141 ++++++++---------- frontend/src/stores/auth.ts | 33 +--- frontend/src/stores/episodes.ts | 85 +++-------- frontend/src/stores/projectMembers.ts | 80 ++++++++++ frontend/src/stores/projects.ts | 77 +++------- frontend/src/stores/settings.ts | 72 ++------- frontend/src/stores/taskStatuses.ts | 69 ++++----- frontend/src/stores/tasks.ts | 34 ++--- frontend/src/views/EpisodesView.vue | 14 +- frontend/src/views/ProjectSettingsView.vue | 7 +- frontend/src/views/ProjectsView.vue | 24 ++- frontend_tasks.md | 81 +++++----- 26 files changed, 453 insertions(+), 492 deletions(-) create mode 100644 frontend/src/composables/useAsyncAction.ts create mode 100644 frontend/src/composables/usePermission.ts create mode 100644 frontend/src/stores/projectMembers.ts diff --git a/frontend/src/components/asset/EditableTaskStatus.vue b/frontend/src/components/asset/EditableTaskStatus.vue index 859db53..18ba4f6 100644 --- a/frontend/src/components/asset/EditableTaskStatus.vue +++ b/frontend/src/components/asset/EditableTaskStatus.vue @@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import { TaskStatus } from '@/services/asset' import { taskService } from '@/services/task' -import { projectService, type ProjectMember } from '@/services/project' +import type { ProjectMember } from '@/services/project' import { useTaskStatusesStore } from '@/stores/taskStatuses' +import { useProjectMembersStore } from '@/stores/projectMembers' import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus' import { useAvatarUrl } from '@/composables/useAvatarUrl' @@ -202,11 +203,12 @@ const { getAvatarUrl } = useAvatarUrl() // Use the shared task statuses store instead of direct API calls const taskStatusesStore = useTaskStatusesStore() +const projectMembersStore = useProjectMembersStore() const isUpdating = ref(false) const isAssigning = ref(false) -const isLoadingMembers = ref(false) -const projectMembers = ref([]) +const projectMembers = computed(() => projectMembersStore.getMembers(props.projectId) || []) +const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId)) const searchQuery = ref('') // Filtered project members based on search query @@ -306,17 +308,12 @@ const fetchStatuses = async () => { } } -// Load project members +// Load project members (shared cache across all EditableTaskStatus instances for this project) const loadProjectMembers = async () => { - if (projectMembers.value.length > 0) return // Already loaded - - isLoadingMembers.value = true try { - projectMembers.value = await projectService.getProjectMembers(props.projectId) + await projectMembersStore.fetchProjectMembers(props.projectId) } catch (error) { console.error('Failed to load project members:', error) - } finally { - isLoadingMembers.value = false } } @@ -400,10 +397,9 @@ onMounted(() => { loadProjectMembers() }) -// Refetch statuses when projectId changes +// Refetch statuses and members when projectId changes watch(() => props.projectId, () => { fetchStatuses() - // Clear project members when project changes - projectMembers.value = [] + loadProjectMembers() }) \ No newline at end of file diff --git a/frontend/src/components/episode/EpisodeDropdown.vue b/frontend/src/components/episode/EpisodeDropdown.vue index 441b6fc..be9b639 100644 --- a/frontend/src/components/episode/EpisodeDropdown.vue +++ b/frontend/src/components/episode/EpisodeDropdown.vue @@ -83,7 +83,7 @@ import { } from '@/components/ui/select' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' -import { useAuthStore } from '@/stores/auth' +import { usePermission } from '@/composables/usePermission' import { episodeService, type Episode } from '@/services/episode' interface Props { @@ -100,7 +100,7 @@ const props = defineProps() const emit = defineEmits() const router = useRouter() -const authStore = useAuthStore() +const { isCoordinatorOrAdmin } = usePermission() // Reactive state const episodes = ref([]) @@ -108,11 +108,7 @@ const isLoading = ref(false) const error = ref(null) // Computed properties -const canCreateEpisodes = computed(() => { - const userRole = authStore.userRole - const isAdmin = authStore.user?.is_admin - return userRole === 'coordinator' || isAdmin -}) +const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value) const sortedEpisodes = computed(() => { return [...episodes.value].sort((a, b) => { diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 803abb4..c0dbd36 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -123,10 +123,12 @@ import { } from 'lucide-vue-next' import { useAuthStore } from '@/stores/auth' +import { usePermission } from '@/composables/usePermission' import ProjectSwitcher from './ProjectSwitcher.vue' import UserMenu from './UserMenu.vue' import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue' const authStore = useAuthStore() +const { isCoordinatorOrAdmin } = usePermission() const { state } = useSidebar() const route = useRoute() const user = computed(() => authStore.user) @@ -150,7 +152,7 @@ const navigationItems = computed(() => { { title: 'My Tasks', url: '/tasks', icon: CheckSquare }, ] - if (userRole.value === 'coordinator' || authStore.isAdmin) { + if (isCoordinatorOrAdmin.value) { baseItems.push( { title: 'Projects', url: '/projects', icon: FolderOpen }, { title: 'Team', url: '/users', icon: Users } diff --git a/frontend/src/components/layout/ProjectSwitcher.vue b/frontend/src/components/layout/ProjectSwitcher.vue index 0f198bd..af9f0dd 100644 --- a/frontend/src/components/layout/ProjectSwitcher.vue +++ b/frontend/src/components/layout/ProjectSwitcher.vue @@ -135,11 +135,13 @@ import { } from "@/components/ui/sidebar"; import { useAuthStore } from "@/stores/auth"; import { useProjectsStore } from "@/stores/projects"; +import { usePermission } from "@/composables/usePermission"; const router = useRouter(); const { isMobile } = useSidebar(); const authStore = useAuthStore(); const projectsStore = useProjectsStore(); +const { isCoordinatorOrAdmin } = usePermission(); // Get projects and active project from store const projects = computed(() => projectsStore.availableProjects); @@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading); const error = computed(() => projectsStore.error); // Check if user can create projects -const canCreateProjects = computed(() => { - const user = authStore.user; - return user?.is_admin || user?.role === "coordinator"; -}); +const canCreateProjects = computed(() => isCoordinatorOrAdmin.value); const setActiveProject = (project: any) => { projectsStore.setActiveProject(project); diff --git a/frontend/src/components/project/TechnicalSpecsManager.vue b/frontend/src/components/project/TechnicalSpecsManager.vue index 7927625..2067c38 100644 --- a/frontend/src/components/project/TechnicalSpecsManager.vue +++ b/frontend/src/components/project/TechnicalSpecsManager.vue @@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue' import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { useAuthStore } from '@/stores/auth' +import { usePermission } from '@/composables/usePermission' import { useToast } from '@/components/ui/toast/use-toast' import { projectService, type ProjectTechnicalSpecs } from '@/services/project' import { notificationService } from '@/services/notifications' @@ -97,7 +97,7 @@ interface Props { const props = defineProps() -const authStore = useAuthStore() +const { isCoordinatorOrAdmin } = usePermission() const { toast } = useToast() // State @@ -108,11 +108,7 @@ const isEditing = ref(false) const showNotification = ref(false) // Computed properties -const canEdit = computed(() => { - const userRole = authStore.userRole - const isAdmin = authStore.user?.is_admin - return userRole === 'coordinator' || isAdmin -}) +const canEdit = computed(() => isCoordinatorOrAdmin.value) // Methods const loadSpecs = async () => { diff --git a/frontend/src/components/project/TechnicalSpecsPanel.vue b/frontend/src/components/project/TechnicalSpecsPanel.vue index 54ebab2..199028d 100644 --- a/frontend/src/components/project/TechnicalSpecsPanel.vue +++ b/frontend/src/components/project/TechnicalSpecsPanel.vue @@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { useAuthStore } from '@/stores/auth' +import { usePermission } from '@/composables/usePermission' import { projectService, type ProjectTechnicalSpecs } from '@/services/project' import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue' import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue' @@ -73,7 +73,7 @@ interface Emits { const props = defineProps() defineEmits() -const authStore = useAuthStore() +const { isCoordinatorOrAdmin } = usePermission() // State const isOpen = ref(props.defaultOpen || false) @@ -82,11 +82,7 @@ const error = ref(null) const specs = ref() // Computed properties -const canEdit = computed(() => { - const userRole = authStore.userRole - const isAdmin = authStore.user?.is_admin - return userRole === 'coordinator' || isAdmin -}) +const canEdit = computed(() => isCoordinatorOrAdmin.value) const hasSpecs = computed(() => { if (!specs.value) return false diff --git a/frontend/src/components/shot/EditableTaskStatus.vue b/frontend/src/components/shot/EditableTaskStatus.vue index 7945567..0b84d5c 100644 --- a/frontend/src/components/shot/EditableTaskStatus.vue +++ b/frontend/src/components/shot/EditableTaskStatus.vue @@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import { TaskStatus } from '@/services/shot' import { taskService } from '@/services/task' -import { projectService, type ProjectMember } from '@/services/project' +import type { ProjectMember } from '@/services/project' import { useTaskStatusesStore } from '@/stores/taskStatuses' +import { useProjectMembersStore } from '@/stores/projectMembers' interface StatusOption { id: string @@ -198,11 +199,12 @@ const emit = defineEmits() // Use the shared task statuses store instead of direct API calls const taskStatusesStore = useTaskStatusesStore() +const projectMembersStore = useProjectMembersStore() const isUpdating = ref(false) const isAssigning = ref(false) -const isLoadingMembers = ref(false) -const projectMembers = ref([]) +const projectMembers = computed(() => projectMembersStore.getMembers(props.projectId) || []) +const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId)) const searchQuery = ref('') // Filtered project members based on search query @@ -306,27 +308,18 @@ const fetchStatuses = async () => { } } -// Load project members +// Load project members (shared cache across all EditableTaskStatus instances for this project) const loadProjectMembers = async () => { - if (projectMembers.value.length > 0) return // Already loaded - - isLoadingMembers.value = true try { - console.log('Loading project members for project:', props.projectId) - projectMembers.value = await projectService.getProjectMembers(props.projectId) - console.log('Loaded project members:', projectMembers.value) + await projectMembersStore.fetchProjectMembers(props.projectId) } catch (error) { console.error('Failed to load project members:', error) - } finally { - isLoadingMembers.value = false } } // Ensure members are loaded when popover is about to open const ensureMembersLoaded = () => { - console.log('Ensuring project members are loaded') if (projectMembers.value.length === 0) { - console.log('Loading project members on button click') loadProjectMembers() } } @@ -407,7 +400,6 @@ onMounted(() => { // Refetch statuses when projectId changes watch(() => props.projectId, () => { fetchStatuses() - // Clear project members when project changes - projectMembers.value = [] + loadProjectMembers() }) diff --git a/frontend/src/components/shot/ShotBrowser.vue b/frontend/src/components/shot/ShotBrowser.vue index 54d8734..4d17ecf 100644 --- a/frontend/src/components/shot/ShotBrowser.vue +++ b/frontend/src/components/shot/ShotBrowser.vue @@ -449,44 +449,64 @@ const loadShots = async () => { const loadEpisodes = async () => { if (!props.projectId) return - + try { const data = await episodeService.getEpisodes(props.projectId) episodes.value = data } catch (err) { console.error('Failed to load episodes:', err) + toast({ + title: 'Failed to load episodes', + description: err instanceof Error ? err.message : 'Episode filtering may be unavailable', + variant: 'destructive' + }) } } const loadTaskTypes = async () => { if (!props.projectId) return - + try { const data = await customTaskTypeService.getAllTaskTypes(props.projectId) allTaskTypes.value = data.shot_task_types || [] } catch (err) { console.error('Failed to load task types:', err) + toast({ + title: 'Failed to load task types', + description: err instanceof Error ? err.message : 'Task columns may be unavailable', + variant: 'destructive' + }) } } const loadTaskStatuses = async () => { if (!props.projectId) return - + try { await taskStatusesStore.fetchProjectStatuses(props.projectId) } catch (err) { console.error('Failed to load task statuses:', err) + toast({ + title: 'Failed to load task statuses', + description: err instanceof Error ? err.message : 'Task status options may be unavailable', + variant: 'destructive' + }) } } const loadProjectContext = async () => { if (!props.projectId) return - + try { const project = await projectService.getProject(props.projectId) projectContext.value = project } catch (err) { console.error('Failed to load project context:', err) + toast({ + title: 'Failed to load project context', + description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable', + variant: 'destructive' + }) } } diff --git a/frontend/src/components/shot/ShotDetailPanel.vue b/frontend/src/components/shot/ShotDetailPanel.vue index 950d164..b7084a5 100644 --- a/frontend/src/components/shot/ShotDetailPanel.vue +++ b/frontend/src/components/shot/ShotDetailPanel.vue @@ -21,7 +21,7 @@ {{ formatStatus(shot.status) }} - + Deleted {{ formatDeletedDate(shot.deleted_at) }} @@ -350,9 +350,9 @@ import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot' import { taskService } from '@/services/task' import { projectService, type ProjectMember } from '@/services/project' -import { useAuthStore } from '@/stores/auth' import { useTaskStatusesStore } from '@/stores/taskStatuses' import { useAvatarUrl } from '@/composables/useAvatarUrl' +import { usePermission } from '@/composables/usePermission' // Use TaskStatusInfo from shot service instead of local Task interface interface Task extends TaskStatusInfo { @@ -384,9 +384,9 @@ interface Emits { const props = defineProps() const emit = defineEmits() -const authStore = useAuthStore() const taskStatusesStore = useTaskStatusesStore() const { getAvatarUrl } = useAvatarUrl() +const { isAdmin, isCoordinatorOrAdmin } = usePermission() // Reactive state const shot = ref(null) @@ -432,25 +432,17 @@ const taskStatusCounts = computed(() => { -const canCreateTask = computed(() => { - return authStore.user?.role === 'coordinator' || authStore.user?.is_admin -}) +const canCreateTask = computed(() => isCoordinatorOrAdmin.value) -const canCreateNote = computed(() => { - return authStore.user?.role === 'coordinator' || authStore.user?.is_admin -}) +const canCreateNote = computed(() => isCoordinatorOrAdmin.value) -const canLinkAssets = computed(() => { - return authStore.user?.role === 'coordinator' || authStore.user?.is_admin -}) +const canLinkAssets = computed(() => isCoordinatorOrAdmin.value) const canUploadReferences = computed(() => { return true // All users can upload references }) -const canEditDesign = computed(() => { - return authStore.user?.role === 'coordinator' || authStore.user?.is_admin -}) +const canEditDesign = computed(() => isCoordinatorOrAdmin.value) const availableTaskTypes = computed(() => { const existingTypes = new Set(tasks.value.map(task => task.task_type)) diff --git a/frontend/src/components/task/TaskDetailPanel.vue b/frontend/src/components/task/TaskDetailPanel.vue index 408355c..bb69552 100644 --- a/frontend/src/components/task/TaskDetailPanel.vue +++ b/frontend/src/components/task/TaskDetailPanel.vue @@ -320,6 +320,7 @@ const emit = defineEmits<{ const { toast } = useToast() const authStore = useAuthStore() +const { isCoordinatorOrAdmin } = usePermission() const task = ref(null) const loading = ref(false) @@ -350,10 +351,7 @@ const canSubmitWork = computed(() => { ) }) -const canReassign = computed(() => { - if (!authStore.user) return false - return authStore.user.is_admin || authStore.user.role === 'coordinator' -}) +const canReassign = computed(() => isCoordinatorOrAdmin.value) async function loadTask() { loading.value = true @@ -478,6 +476,7 @@ function getUserInitials(member: ProjectMember): string { } import { useAvatarUrl } from '@/composables/useAvatarUrl' +import { usePermission } from '@/composables/usePermission' const { getAvatarUrl } = useAvatarUrl() diff --git a/frontend/src/components/task/TaskList.vue b/frontend/src/components/task/TaskList.vue index 96da93c..72531ba 100644 --- a/frontend/src/components/task/TaskList.vue +++ b/frontend/src/components/task/TaskList.vue @@ -299,7 +299,7 @@