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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ProjectMember[]>([])
|
||||
const projectMembers = computed<ProjectMember[]>(() => 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()
|
||||
})
|
||||
</script>
|
||||
@@ -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<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const episodes = ref<Episode[]>([])
|
||||
@@ -108,11 +108,7 @@ const isLoading = ref(false)
|
||||
const error = ref<string | null>(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) => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Props>()
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// State
|
||||
const isOpen = ref(props.defaultOpen || false)
|
||||
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
|
||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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<Emits>()
|
||||
|
||||
// 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<ProjectMember[]>([])
|
||||
const projectMembers = computed<ProjectMember[]>(() => 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()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -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<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const shot = ref<Shot | null>(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))
|
||||
|
||||
@@ -320,6 +320,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
const task = ref<Task | null>(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()
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -344,7 +344,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const tasksStore = useTasksStore()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { toast } = useToast()
|
||||
|
||||
const searchQuery = ref('')
|
||||
@@ -361,9 +361,7 @@ const assignmentDepartmentFilter = ref('all')
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const assigningTask = ref(false)
|
||||
|
||||
const canAssignTasks = computed(() => {
|
||||
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
|
||||
})
|
||||
const canAssignTasks = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const showDepartmentFilter = computed(() => {
|
||||
return canAssignTasks.value
|
||||
|
||||
Reference in New Issue
Block a user