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 TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/asset'
|
import { TaskStatus } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import type { ProjectMember } from '@/services/project'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
@@ -202,11 +203,12 @@ const { getAvatarUrl } = useAvatarUrl()
|
|||||||
|
|
||||||
// Use the shared task statuses store instead of direct API calls
|
// Use the shared task statuses store instead of direct API calls
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const projectMembersStore = useProjectMembersStore()
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false)
|
||||||
const isAssigning = ref(false)
|
const isAssigning = ref(false)
|
||||||
const isLoadingMembers = ref(false)
|
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||||
const projectMembers = ref<ProjectMember[]>([])
|
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Filtered project members based on search query
|
// 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 () => {
|
const loadProjectMembers = async () => {
|
||||||
if (projectMembers.value.length > 0) return // Already loaded
|
|
||||||
|
|
||||||
isLoadingMembers.value = true
|
|
||||||
try {
|
try {
|
||||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load project members:', error)
|
console.error('Failed to load project members:', error)
|
||||||
} finally {
|
|
||||||
isLoadingMembers.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +397,9 @@ onMounted(() => {
|
|||||||
loadProjectMembers()
|
loadProjectMembers()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refetch statuses when projectId changes
|
// Refetch statuses and members when projectId changes
|
||||||
watch(() => props.projectId, () => {
|
watch(() => props.projectId, () => {
|
||||||
fetchStatuses()
|
fetchStatuses()
|
||||||
// Clear project members when project changes
|
loadProjectMembers()
|
||||||
projectMembers.value = []
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -83,7 +83,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { usePermission } from '@/composables/usePermission'
|
||||||
import { episodeService, type Episode } from '@/services/episode'
|
import { episodeService, type Episode } from '@/services/episode'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const episodes = ref<Episode[]>([])
|
const episodes = ref<Episode[]>([])
|
||||||
@@ -108,11 +108,7 @@ const isLoading = ref(false)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canCreateEpisodes = computed(() => {
|
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
const sortedEpisodes = computed(() => {
|
const sortedEpisodes = computed(() => {
|
||||||
return [...episodes.value].sort((a, b) => {
|
return [...episodes.value].sort((a, b) => {
|
||||||
|
|||||||
@@ -123,10 +123,12 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
import ProjectSwitcher from './ProjectSwitcher.vue'
|
import ProjectSwitcher from './ProjectSwitcher.vue'
|
||||||
import UserMenu from './UserMenu.vue'
|
import UserMenu from './UserMenu.vue'
|
||||||
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
|
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
const { state } = useSidebar()
|
const { state } = useSidebar()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const user = computed(() => authStore.user)
|
const user = computed(() => authStore.user)
|
||||||
@@ -150,7 +152,7 @@ const navigationItems = computed(() => {
|
|||||||
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
||||||
]
|
]
|
||||||
|
|
||||||
if (userRole.value === 'coordinator' || authStore.isAdmin) {
|
if (isCoordinatorOrAdmin.value) {
|
||||||
baseItems.push(
|
baseItems.push(
|
||||||
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
||||||
{ title: 'Team', url: '/users', icon: Users }
|
{ title: 'Team', url: '/users', icon: Users }
|
||||||
|
|||||||
@@ -135,11 +135,13 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useProjectsStore } from "@/stores/projects";
|
import { useProjectsStore } from "@/stores/projects";
|
||||||
|
import { usePermission } from "@/composables/usePermission";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission();
|
||||||
|
|
||||||
// Get projects and active project from store
|
// Get projects and active project from store
|
||||||
const projects = computed(() => projectsStore.availableProjects);
|
const projects = computed(() => projectsStore.availableProjects);
|
||||||
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
|
|||||||
const error = computed(() => projectsStore.error);
|
const error = computed(() => projectsStore.error);
|
||||||
|
|
||||||
// Check if user can create projects
|
// Check if user can create projects
|
||||||
const canCreateProjects = computed(() => {
|
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
|
||||||
const user = authStore.user;
|
|
||||||
return user?.is_admin || user?.role === "coordinator";
|
|
||||||
});
|
|
||||||
|
|
||||||
const setActiveProject = (project: any) => {
|
const setActiveProject = (project: any) => {
|
||||||
projectsStore.setActiveProject(project);
|
projectsStore.setActiveProject(project);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
|
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card } from '@/components/ui/card'
|
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 { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||||
import { notificationService } from '@/services/notifications'
|
import { notificationService } from '@/services/notifications'
|
||||||
@@ -97,7 +97,7 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -108,11 +108,7 @@ const isEditing = ref(false)
|
|||||||
const showNotification = ref(false)
|
const showNotification = ref(false)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadSpecs = async () => {
|
const loadSpecs = async () => {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
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 { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||||
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
||||||
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
||||||
@@ -73,7 +73,7 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
defineEmits<Emits>()
|
defineEmits<Emits>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isOpen = ref(props.defaultOpen || false)
|
const isOpen = ref(props.defaultOpen || false)
|
||||||
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
|
|||||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasSpecs = computed(() => {
|
const hasSpecs = computed(() => {
|
||||||
if (!specs.value) return false
|
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 TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/shot'
|
import { TaskStatus } from '@/services/shot'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import type { ProjectMember } from '@/services/project'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||||
|
|
||||||
interface StatusOption {
|
interface StatusOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
|
|||||||
|
|
||||||
// Use the shared task statuses store instead of direct API calls
|
// Use the shared task statuses store instead of direct API calls
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const projectMembersStore = useProjectMembersStore()
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false)
|
||||||
const isAssigning = ref(false)
|
const isAssigning = ref(false)
|
||||||
const isLoadingMembers = ref(false)
|
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||||
const projectMembers = ref<ProjectMember[]>([])
|
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Filtered project members based on search query
|
// 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 () => {
|
const loadProjectMembers = async () => {
|
||||||
if (projectMembers.value.length > 0) return // Already loaded
|
|
||||||
|
|
||||||
isLoadingMembers.value = true
|
|
||||||
try {
|
try {
|
||||||
console.log('Loading project members for project:', props.projectId)
|
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
|
||||||
console.log('Loaded project members:', projectMembers.value)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load project members:', error)
|
console.error('Failed to load project members:', error)
|
||||||
} finally {
|
|
||||||
isLoadingMembers.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure members are loaded when popover is about to open
|
// Ensure members are loaded when popover is about to open
|
||||||
const ensureMembersLoaded = () => {
|
const ensureMembersLoaded = () => {
|
||||||
console.log('Ensuring project members are loaded')
|
|
||||||
if (projectMembers.value.length === 0) {
|
if (projectMembers.value.length === 0) {
|
||||||
console.log('Loading project members on button click')
|
|
||||||
loadProjectMembers()
|
loadProjectMembers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +400,6 @@ onMounted(() => {
|
|||||||
// Refetch statuses when projectId changes
|
// Refetch statuses when projectId changes
|
||||||
watch(() => props.projectId, () => {
|
watch(() => props.projectId, () => {
|
||||||
fetchStatuses()
|
fetchStatuses()
|
||||||
// Clear project members when project changes
|
loadProjectMembers()
|
||||||
projectMembers.value = []
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -449,44 +449,64 @@ const loadShots = async () => {
|
|||||||
|
|
||||||
const loadEpisodes = async () => {
|
const loadEpisodes = async () => {
|
||||||
if (!props.projectId) return
|
if (!props.projectId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await episodeService.getEpisodes(props.projectId)
|
const data = await episodeService.getEpisodes(props.projectId)
|
||||||
episodes.value = data
|
episodes.value = data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load episodes:', 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 () => {
|
const loadTaskTypes = async () => {
|
||||||
if (!props.projectId) return
|
if (!props.projectId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
const data = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||||
allTaskTypes.value = data.shot_task_types || []
|
allTaskTypes.value = data.shot_task_types || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load task types:', 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 () => {
|
const loadTaskStatuses = async () => {
|
||||||
if (!props.projectId) return
|
if (!props.projectId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load task statuses:', 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 () => {
|
const loadProjectContext = async () => {
|
||||||
if (!props.projectId) return
|
if (!props.projectId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await projectService.getProject(props.projectId)
|
const project = await projectService.getProject(props.projectId)
|
||||||
projectContext.value = project
|
projectContext.value = project
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project context:', 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) }}
|
{{ formatStatus(shot.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<!-- Deletion status indicator for admins -->
|
<!-- 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) }}
|
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
@@ -350,9 +350,9 @@ import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
|||||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
|
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
|
||||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||||
interface Task extends TaskStatusInfo {
|
interface Task extends TaskStatusInfo {
|
||||||
@@ -384,9 +384,9 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
const { getAvatarUrl } = useAvatarUrl()
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const shot = ref<Shot | null>(null)
|
const shot = ref<Shot | null>(null)
|
||||||
@@ -432,25 +432,17 @@ const taskStatusCounts = computed(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const canCreateTask = computed(() => {
|
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
||||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCreateNote = computed(() => {
|
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
|
||||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
|
||||||
})
|
|
||||||
|
|
||||||
const canLinkAssets = computed(() => {
|
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
|
||||||
})
|
|
||||||
|
|
||||||
const canUploadReferences = computed(() => {
|
const canUploadReferences = computed(() => {
|
||||||
return true // All users can upload references
|
return true // All users can upload references
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEditDesign = computed(() => {
|
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
||||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableTaskTypes = computed(() => {
|
const availableTaskTypes = computed(() => {
|
||||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
const task = ref<Task | null>(null)
|
const task = ref<Task | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -350,10 +351,7 @@ const canSubmitWork = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const canReassign = computed(() => {
|
const canReassign = computed(() => isCoordinatorOrAdmin.value)
|
||||||
if (!authStore.user) return false
|
|
||||||
return authStore.user.is_admin || authStore.user.role === 'coordinator'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -478,6 +476,7 @@ function getUserInitials(member: ProjectMember): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
|
|
||||||
const { getAvatarUrl } = useAvatarUrl()
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
|
|||||||
@@ -299,7 +299,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useTasksStore } from '@/stores/tasks'
|
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 { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -344,7 +344,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tasksStore = useTasksStore()
|
const tasksStore = useTasksStore()
|
||||||
const authStore = useAuthStore()
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -361,9 +361,7 @@ const assignmentDepartmentFilter = ref('all')
|
|||||||
const projectMembers = ref<ProjectMember[]>([])
|
const projectMembers = ref<ProjectMember[]>([])
|
||||||
const assigningTask = ref(false)
|
const assigningTask = ref(false)
|
||||||
|
|
||||||
const canAssignTasks = computed(() => {
|
const canAssignTasks = computed(() => isCoordinatorOrAdmin.value)
|
||||||
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
|
|
||||||
})
|
|
||||||
|
|
||||||
const showDepartmentFilter = computed(() => {
|
const showDepartmentFilter = computed(() => {
|
||||||
return canAssignTasks.value
|
return canAssignTasks.value
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
interface UseAsyncActionOptions {
|
||||||
|
isLoading: Ref<boolean>
|
||||||
|
error: Ref<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunOptions {
|
||||||
|
/** Fallback message shown when the error has no server-provided detail */
|
||||||
|
errorMessage: string
|
||||||
|
/** Whether to re-throw after recording the error (default true) */
|
||||||
|
rethrow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the isLoading/error/try-catch-finally shape shared by most store actions:
|
||||||
|
* set loading, clear error, run fn, extract a message from an axios-shaped or
|
||||||
|
* plain Error on failure, log it, optionally re-throw, and always clear loading.
|
||||||
|
*/
|
||||||
|
export function useAsyncAction({ isLoading, error }: UseAsyncActionOptions) {
|
||||||
|
// Overloads so callers that don't pass `rethrow: false` keep a non-optional return type
|
||||||
|
function run<T>(fn: () => Promise<T>, options: RunOptions & { rethrow?: true }): Promise<T>
|
||||||
|
function run<T>(fn: () => Promise<T>, options: RunOptions & { rethrow: false }): Promise<T | undefined>
|
||||||
|
async function run<T>(fn: () => Promise<T>, options: RunOptions): Promise<T | undefined> {
|
||||||
|
const { errorMessage, rethrow = true } = options
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
return await fn()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.response?.data?.detail || (err instanceof Error ? err.message : errorMessage)
|
||||||
|
console.error(errorMessage, err)
|
||||||
|
if (rethrow) throw err
|
||||||
|
return undefined
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared role/admin checks for the dominant duplicated permission pattern
|
||||||
|
* (`is_admin || role === 'coordinator'`) found across ~15 components.
|
||||||
|
* Not a generic `can()` — a couple of call sites check genuinely different
|
||||||
|
* things (ownership, a third "developer" role) and are left as-is.
|
||||||
|
*/
|
||||||
|
export function usePermission() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isAdmin = computed(() => !!authStore.user?.is_admin)
|
||||||
|
const isCoordinatorOrAdmin = computed(() =>
|
||||||
|
authStore.user?.role === 'coordinator' || !!authStore.user?.is_admin
|
||||||
|
)
|
||||||
|
|
||||||
|
return { isAdmin, isCoordinatorOrAdmin }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { toast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -192,12 +193,17 @@ const router = createRouter({
|
|||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// Initialize auth if not already done
|
// Initialize auth if not already done. Note: initializeAuth() never throws — on
|
||||||
|
// failure it logs a warning and clears auth state internally — so we detect failure
|
||||||
|
// by checking whether we're still unauthenticated afterward, not via try/catch.
|
||||||
if (!authStore.user && authStore.accessToken) {
|
if (!authStore.user && authStore.accessToken) {
|
||||||
try {
|
await authStore.initializeAuth()
|
||||||
await authStore.initializeAuth()
|
if (!authStore.isAuthenticated) {
|
||||||
} catch (error) {
|
toast({
|
||||||
console.error('Auth initialization failed:', error)
|
title: 'Session expired',
|
||||||
|
description: 'Please log in again to continue.',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,54 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { assetService, type Asset, type AssetCreate, type AssetUpdate, AssetCategory, TaskStatus } from '@/services/asset'
|
import { assetService, type Asset, type AssetCreate, type AssetUpdate, AssetCategory, TaskStatus } from '@/services/asset'
|
||||||
|
|
||||||
|
// Shared optimistic-update helpers for a single asset's task status, used by both
|
||||||
|
// updateTaskStatus and bulkUpdateTaskStatus — the two API calls they wrap are genuinely
|
||||||
|
// different (single-task endpoint vs. a distinct bulk endpoint), so only the per-asset
|
||||||
|
// snapshot/apply/rollback of local state is shared, not the outer functions themselves.
|
||||||
|
interface AssetTaskStatusSnapshot {
|
||||||
|
taskStatus?: TaskStatus
|
||||||
|
taskDetailStatus?: TaskStatus
|
||||||
|
taskDetailTaskId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAssetTaskStatus(asset: Asset, taskType: string): AssetTaskStatusSnapshot {
|
||||||
|
const taskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
||||||
|
return {
|
||||||
|
taskStatus: asset.task_status?.[taskType],
|
||||||
|
taskDetailStatus: taskDetail?.status,
|
||||||
|
taskDetailTaskId: taskDetail?.task_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAssetTaskStatus(asset: Asset, taskType: string, newStatus: TaskStatus) {
|
||||||
|
if (asset.task_status) {
|
||||||
|
asset.task_status[taskType] = newStatus
|
||||||
|
}
|
||||||
|
const taskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
||||||
|
if (taskDetail) {
|
||||||
|
taskDetail.status = newStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollbackAssetTaskStatus(
|
||||||
|
asset: Asset,
|
||||||
|
taskType: string,
|
||||||
|
snapshot: AssetTaskStatusSnapshot,
|
||||||
|
options: { restoreTaskId?: boolean } = {}
|
||||||
|
) {
|
||||||
|
if (asset.task_status && snapshot.taskStatus !== undefined) {
|
||||||
|
asset.task_status[taskType] = snapshot.taskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
||||||
|
if (taskDetail && snapshot.taskDetailStatus !== undefined) {
|
||||||
|
taskDetail.status = snapshot.taskDetailStatus
|
||||||
|
if (options.restoreTaskId && snapshot.taskDetailTaskId !== undefined) {
|
||||||
|
taskDetail.task_id = snapshot.taskDetailTaskId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useAssetsStore = defineStore('assets', () => {
|
export const useAssetsStore = defineStore('assets', () => {
|
||||||
// State
|
// State
|
||||||
const assets = ref<Asset[]>([])
|
const assets = ref<Asset[]>([])
|
||||||
@@ -159,25 +207,11 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
if (assetIndex === -1) return
|
if (assetIndex === -1) return
|
||||||
|
|
||||||
const asset = assets.value[assetIndex]
|
const asset = assets.value[assetIndex]
|
||||||
|
|
||||||
// Store original status for rollback
|
// Store original status for rollback, then apply the optimistic update
|
||||||
const originalStatus = asset.task_status?.[taskType]
|
const snapshot = snapshotAssetTaskStatus(asset, taskType)
|
||||||
const originalTaskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
applyAssetTaskStatus(asset, taskType, newStatus)
|
||||||
const originalTaskDetailStatus = originalTaskDetail?.status
|
|
||||||
|
|
||||||
// Optimistic update - update the task status in the asset immediately
|
|
||||||
if (asset.task_status) {
|
|
||||||
asset.task_status[taskType] = newStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the task details if available
|
|
||||||
if (asset.task_details) {
|
|
||||||
const taskDetail = asset.task_details.find(task => task.task_type === taskType)
|
|
||||||
if (taskDetail) {
|
|
||||||
taskDetail.status = newStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the current asset if it's the same
|
// Update the current asset if it's the same
|
||||||
if (currentAsset.value?.id === assetId) {
|
if (currentAsset.value?.id === assetId) {
|
||||||
currentAsset.value = { ...asset }
|
currentAsset.value = { ...asset }
|
||||||
@@ -216,19 +250,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback optimistic update on error
|
// Rollback optimistic update on error
|
||||||
if (asset.task_status && originalStatus !== undefined) {
|
rollbackAssetTaskStatus(asset, taskType, snapshot)
|
||||||
asset.task_status[taskType] = originalStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalTaskDetail && originalTaskDetailStatus !== undefined) {
|
|
||||||
originalTaskDetail.status = originalTaskDetailStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the current asset if it's the same
|
// Update the current asset if it's the same
|
||||||
if (currentAsset.value?.id === assetId) {
|
if (currentAsset.value?.id === assetId) {
|
||||||
currentAsset.value = { ...asset }
|
currentAsset.value = { ...asset }
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,10 +265,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
if (assetIds.length === 0) return
|
if (assetIds.length === 0) return
|
||||||
|
|
||||||
// Store original states for rollback
|
// Store original states for rollback
|
||||||
const originalStates = new Map<number, {
|
const originalStates = new Map<number, AssetTaskStatusSnapshot>()
|
||||||
taskStatus?: TaskStatus
|
|
||||||
taskDetail?: { status: TaskStatus; task_id?: number }
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Optimistic updates
|
// Optimistic updates
|
||||||
for (const assetId of assetIds) {
|
for (const assetId of assetIds) {
|
||||||
@@ -248,30 +273,10 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
if (assetIndex === -1) continue
|
if (assetIndex === -1) continue
|
||||||
|
|
||||||
const asset = assets.value[assetIndex]
|
const asset = assets.value[assetIndex]
|
||||||
|
|
||||||
// Store original state
|
|
||||||
const originalTaskStatus = asset.task_status?.[taskType]
|
|
||||||
const originalTaskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
|
||||||
originalStates.set(assetId, {
|
|
||||||
taskStatus: originalTaskStatus,
|
|
||||||
taskDetail: originalTaskDetail ? {
|
|
||||||
status: originalTaskDetail.status,
|
|
||||||
task_id: originalTaskDetail.task_id
|
|
||||||
} : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apply optimistic update
|
originalStates.set(assetId, snapshotAssetTaskStatus(asset, taskType))
|
||||||
if (asset.task_status) {
|
applyAssetTaskStatus(asset, taskType, newStatus)
|
||||||
asset.task_status[taskType] = newStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.task_details) {
|
|
||||||
const taskDetail = asset.task_details.find(task => task.task_type === taskType)
|
|
||||||
if (taskDetail) {
|
|
||||||
taskDetail.status = newStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current asset if it's the same
|
// Update current asset if it's the same
|
||||||
if (currentAsset.value?.id === assetId) {
|
if (currentAsset.value?.id === assetId) {
|
||||||
currentAsset.value = { ...asset }
|
currentAsset.value = { ...asset }
|
||||||
@@ -325,32 +330,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
if (assetIndex === -1) continue
|
if (assetIndex === -1) continue
|
||||||
|
|
||||||
const asset = assets.value[assetIndex]
|
const asset = assets.value[assetIndex]
|
||||||
const originalState = originalStates.get(assetId)
|
const snapshot = originalStates.get(assetId)
|
||||||
|
|
||||||
if (originalState) {
|
if (snapshot) {
|
||||||
// Rollback task status
|
rollbackAssetTaskStatus(asset, taskType, snapshot, { restoreTaskId: true })
|
||||||
if (asset.task_status && originalState.taskStatus !== undefined) {
|
|
||||||
asset.task_status[taskType] = originalState.taskStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollback task detail
|
|
||||||
if (originalState.taskDetail) {
|
|
||||||
const taskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
|
||||||
if (taskDetail) {
|
|
||||||
taskDetail.status = originalState.taskDetail.status
|
|
||||||
if (originalState.taskDetail.task_id !== undefined) {
|
|
||||||
taskDetail.task_id = originalState.taskDetail.task_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current asset if it's the same
|
// Update current asset if it's the same
|
||||||
if (currentAsset.value?.id === assetId) {
|
if (currentAsset.value?.id === assetId) {
|
||||||
currentAsset.value = { ...asset }
|
currentAsset.value = { ...asset }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { authService } from '@/services/auth'
|
import { authService } from '@/services/auth'
|
||||||
import type { User, LoginCredentials, RegisterData, LoginResponse } from '@/types/auth'
|
import type { User, LoginCredentials, RegisterData, LoginResponse } from '@/types/auth'
|
||||||
|
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// State
|
// State
|
||||||
@@ -10,6 +11,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const { run } = useAsyncAction({ isLoading, error })
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||||
@@ -18,43 +20,24 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const response = await authService.login(credentials)
|
const response = await authService.login(credentials)
|
||||||
|
|
||||||
// Store tokens
|
// Store tokens
|
||||||
accessToken.value = response.access_token
|
accessToken.value = response.access_token
|
||||||
refreshToken.value = response.refresh_token
|
refreshToken.value = response.refresh_token
|
||||||
localStorage.setItem('access_token', response.access_token)
|
localStorage.setItem('access_token', response.access_token)
|
||||||
localStorage.setItem('refresh_token', response.refresh_token)
|
localStorage.setItem('refresh_token', response.refresh_token)
|
||||||
|
|
||||||
// Get user profile
|
// Get user profile
|
||||||
await getCurrentUser()
|
await getCurrentUser()
|
||||||
|
|
||||||
return response
|
return response
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Login failed' })
|
||||||
error.value = err.response?.data?.detail || 'Login failed'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const register = async (data: RegisterData) => {
|
const register = async (data: RegisterData) => {
|
||||||
try {
|
return run(() => authService.register(data), { errorMessage: 'Registration failed' })
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const response = await authService.register(data)
|
|
||||||
return response
|
|
||||||
} catch (err: any) {
|
|
||||||
error.value = err.response?.data?.detail || 'Registration failed'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
||||||
|
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||||
|
|
||||||
export const useEpisodesStore = defineStore('episodes', () => {
|
export const useEpisodesStore = defineStore('episodes', () => {
|
||||||
// State
|
// State
|
||||||
@@ -8,6 +9,7 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
|||||||
const currentEpisode = ref<Episode | null>(null)
|
const currentEpisode = ref<Episode | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const { run } = useAsyncAction({ isLoading, error })
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const getEpisodeById = computed(() => {
|
const getEpisodeById = computed(() => {
|
||||||
@@ -24,30 +26,17 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const fetchEpisodes = async (projectId?: number) => {
|
const fetchEpisodes = async (projectId?: number) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
episodes.value = projectId
|
||||||
error.value = null
|
? await episodeService.getProjectEpisodes(projectId)
|
||||||
|
: await episodeService.getEpisodes()
|
||||||
if (projectId) {
|
}, { errorMessage: 'Failed to fetch episodes' })
|
||||||
episodes.value = await episodeService.getProjectEpisodes(projectId)
|
|
||||||
} else {
|
|
||||||
episodes.value = await episodeService.getEpisodes()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch episodes'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchEpisode = async (episodeId: number) => {
|
const fetchEpisode = async (episodeId: number) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const episode = await episodeService.getEpisode(episodeId)
|
const episode = await episodeService.getEpisode(episodeId)
|
||||||
|
|
||||||
// Update the episode in the list if it exists
|
// Update the episode in the list if it exists
|
||||||
const index = episodes.value.findIndex(e => e.id === episodeId)
|
const index = episodes.value.findIndex(e => e.id === episodeId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -55,81 +44,51 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
|||||||
} else {
|
} else {
|
||||||
episodes.value.push(episode)
|
episodes.value.push(episode)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentEpisode.value = episode
|
currentEpisode.value = episode
|
||||||
return episode
|
return episode
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to fetch episode' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch episode'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEpisode = async (projectId: number, episodeData: EpisodeCreate) => {
|
const createEpisode = async (projectId: number, episodeData: EpisodeCreate) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const newEpisode = await episodeService.createEpisode(projectId, episodeData)
|
const newEpisode = await episodeService.createEpisode(projectId, episodeData)
|
||||||
episodes.value.push(newEpisode)
|
episodes.value.push(newEpisode)
|
||||||
|
|
||||||
return newEpisode
|
return newEpisode
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to create episode' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to create episode'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateEpisode = async (episodeId: number, episodeData: EpisodeUpdate) => {
|
const updateEpisode = async (episodeId: number, episodeData: EpisodeUpdate) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const updatedEpisode = await episodeService.updateEpisode(episodeId, episodeData)
|
const updatedEpisode = await episodeService.updateEpisode(episodeId, episodeData)
|
||||||
|
|
||||||
// Update the episode in the list
|
// Update the episode in the list
|
||||||
const index = episodes.value.findIndex(e => e.id === episodeId)
|
const index = episodes.value.findIndex(e => e.id === episodeId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
episodes.value[index] = updatedEpisode
|
episodes.value[index] = updatedEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current episode if it's the same
|
// Update current episode if it's the same
|
||||||
if (currentEpisode.value?.id === episodeId) {
|
if (currentEpisode.value?.id === episodeId) {
|
||||||
currentEpisode.value = updatedEpisode
|
currentEpisode.value = updatedEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedEpisode
|
return updatedEpisode
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to update episode' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to update episode'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteEpisode = async (episodeId: number) => {
|
const deleteEpisode = async (episodeId: number) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
await episodeService.deleteEpisode(episodeId)
|
await episodeService.deleteEpisode(episodeId)
|
||||||
|
|
||||||
// Remove the episode from the list
|
// Remove the episode from the list
|
||||||
episodes.value = episodes.value.filter(e => e.id !== episodeId)
|
episodes.value = episodes.value.filter(e => e.id !== episodeId)
|
||||||
|
|
||||||
// Clear current episode if it's the deleted one
|
// Clear current episode if it's the deleted one
|
||||||
if (currentEpisode.value?.id === episodeId) {
|
if (currentEpisode.value?.id === episodeId) {
|
||||||
currentEpisode.value = null
|
currentEpisode.value = null
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to delete episode' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to delete episode'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCurrentEpisode = (episode: Episode | null) => {
|
const setCurrentEpisode = (episode: Episode | null) => {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
|
|
||||||
|
interface CachedProjectMembers {
|
||||||
|
data: ProjectMember[]
|
||||||
|
lastFetched: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectMembersStore = defineStore('projectMembers', () => {
|
||||||
|
const membersByProject = ref<Map<number, CachedProjectMembers>>(new Map())
|
||||||
|
const loading = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
// In-flight request de-dup: concurrent callers for the same project share one promise
|
||||||
|
const inFlightRequests = new Map<number, Promise<ProjectMember[]>>()
|
||||||
|
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000
|
||||||
|
|
||||||
|
const getMembers = computed(() => {
|
||||||
|
return (projectId: number): ProjectMember[] | null => {
|
||||||
|
const cached = membersByProject.value.get(projectId)
|
||||||
|
if (!cached) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - cached.lastFetched > CACHE_DURATION) {
|
||||||
|
membersByProject.value.delete(projectId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
return (projectId: number): boolean => {
|
||||||
|
return loading.value.has(projectId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchProjectMembers(projectId: number, force = false): Promise<ProjectMember[]> {
|
||||||
|
if (!force) {
|
||||||
|
const cached = getMembers.value(projectId)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = inFlightRequests.get(projectId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value.add(projectId)
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await projectService.getProjectMembers(projectId)
|
||||||
|
membersByProject.value.set(projectId, { data, lastFetched: Date.now() })
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
loading.value.delete(projectId)
|
||||||
|
inFlightRequests.delete(projectId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inFlightRequests.set(projectId, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateProject(projectId: number) {
|
||||||
|
membersByProject.value.delete(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getMembers,
|
||||||
|
isLoading,
|
||||||
|
fetchProjectMembers,
|
||||||
|
invalidateProject
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, markRaw } from 'vue'
|
||||||
import { Film, Palette, Zap, Folder } from 'lucide-vue-next'
|
import { Film, Palette, Zap, Folder } from 'lucide-vue-next'
|
||||||
import { projectService, type Project as ProjectType, type ProjectCreate, type ProjectUpdate } from '@/services/project'
|
import { projectService, type Project as ProjectType, type ProjectCreate, type ProjectUpdate } from '@/services/project'
|
||||||
|
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||||
|
|
||||||
export interface Project extends ProjectType {
|
export interface Project extends ProjectType {
|
||||||
icon?: any
|
icon?: any
|
||||||
@@ -13,13 +14,14 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
const activeProject = ref<Project | null>(null)
|
const activeProject = ref<Project | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const { run } = useAsyncAction({ isLoading, error })
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const allProjectsView: Project = {
|
const allProjectsView: Project = {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: 'All Projects',
|
name: 'All Projects',
|
||||||
status: 'planning',
|
status: 'planning',
|
||||||
icon: Folder,
|
icon: markRaw(Folder),
|
||||||
description: 'View all projects',
|
description: 'View all projects',
|
||||||
created_at: '',
|
created_at: '',
|
||||||
updated_at: ''
|
updated_at: ''
|
||||||
@@ -83,105 +85,66 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...project, icon }
|
return { ...project, icon: markRaw(icon) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
const fetchedProjects = await projectService.getUserProjects()
|
const fetchedProjects = await projectService.getUserProjects()
|
||||||
projects.value = fetchedProjects.map(assignProjectIcon)
|
projects.value = fetchedProjects.map(assignProjectIcon)
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to fetch projects', rethrow: false })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch projects'
|
|
||||||
console.error('Failed to fetch projects:', err)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createProject = async (projectData: ProjectCreate) => {
|
const createProject = async (projectData: ProjectCreate) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
const newProject = await projectService.createProject(projectData)
|
const newProject = await projectService.createProject(projectData)
|
||||||
const projectWithIcon = assignProjectIcon(newProject)
|
const projectWithIcon = assignProjectIcon(newProject)
|
||||||
projects.value.push(projectWithIcon)
|
projects.value.push(projectWithIcon)
|
||||||
return projectWithIcon
|
return projectWithIcon
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to create project' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to create project'
|
|
||||||
console.error('Failed to create project:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateProject = async (id: number, updates: ProjectUpdate) => {
|
const updateProject = async (id: number, updates: ProjectUpdate) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
const updatedProject = await projectService.updateProject(id, updates)
|
const updatedProject = await projectService.updateProject(id, updates)
|
||||||
const projectWithIcon = assignProjectIcon(updatedProject)
|
const projectWithIcon = assignProjectIcon(updatedProject)
|
||||||
|
|
||||||
const index = projects.value.findIndex(p => p.id === id)
|
const index = projects.value.findIndex(p => p.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
projects.value[index] = projectWithIcon
|
projects.value[index] = projectWithIcon
|
||||||
|
|
||||||
// Update active project if it's the one being updated
|
// Update active project if it's the one being updated
|
||||||
if (activeProject.value?.id === id) {
|
if (activeProject.value?.id === id) {
|
||||||
activeProject.value = projectWithIcon
|
activeProject.value = projectWithIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectWithIcon
|
return projectWithIcon
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to update project' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to update project'
|
|
||||||
console.error('Failed to update project:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteProject = async (id: number) => {
|
const deleteProject = async (id: number) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
await projectService.deleteProject(id)
|
await projectService.deleteProject(id)
|
||||||
|
|
||||||
const index = projects.value.findIndex(p => p.id === id)
|
const index = projects.value.findIndex(p => p.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
projects.value.splice(index, 1)
|
projects.value.splice(index, 1)
|
||||||
|
|
||||||
// If the removed project was active, switch to all projects view
|
// If the removed project was active, switch to all projects view
|
||||||
if (activeProject.value?.id === id) {
|
if (activeProject.value?.id === id) {
|
||||||
activeProject.value = allProjectsView
|
activeProject.value = allProjectsView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to delete project' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to delete project'
|
|
||||||
console.error('Failed to delete project:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProject = async (id: number, includeMembers: boolean = false) => {
|
const getProject = async (id: number, includeMembers: boolean = false) => {
|
||||||
try {
|
return run(async () => {
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
const project = await projectService.getProject(id, includeMembers)
|
const project = await projectService.getProject(id, includeMembers)
|
||||||
return assignProjectIcon(project)
|
return assignProjectIcon(project)
|
||||||
} catch (err) {
|
}, { errorMessage: 'Failed to fetch project' })
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch project'
|
|
||||||
console.error('Failed to fetch project:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setActiveProject = (project: Project) => {
|
const setActiveProject = (project: Project) => {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { settingsService, type UploadLimitResponse, type GlobalSetting } from '@/services/settings'
|
import { settingsService, type UploadLimitResponse, type GlobalSetting } from '@/services/settings'
|
||||||
|
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const uploadLimit = ref<UploadLimitResponse | null>(null)
|
const uploadLimit = ref<UploadLimitResponse | null>(null)
|
||||||
const allSettings = ref<GlobalSetting[]>([])
|
const allSettings = ref<GlobalSetting[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const { run } = useAsyncAction({ isLoading: loading, error })
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const uploadLimitMB = computed(() => uploadLimit.value?.upload_limit_mb || 1000)
|
const uploadLimitMB = computed(() => uploadLimit.value?.upload_limit_mb || 1000)
|
||||||
@@ -14,93 +16,47 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function fetchUploadLimit() {
|
async function fetchUploadLimit() {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
uploadLimit.value = await settingsService.getUploadLimit()
|
uploadLimit.value = await settingsService.getUploadLimit()
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to fetch upload limit', rethrow: false })
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch upload limit'
|
|
||||||
console.error('Error fetching upload limit:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUploadLimit(limitMB: number) {
|
async function updateUploadLimit(limitMB: number) {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
uploadLimit.value = await settingsService.updateUploadLimit({ upload_limit_mb: limitMB })
|
uploadLimit.value = await settingsService.updateUploadLimit({ upload_limit_mb: limitMB })
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to update upload limit' })
|
||||||
error.value = err.response?.data?.detail || 'Failed to update upload limit'
|
|
||||||
console.error('Error updating upload limit:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllSettings() {
|
async function fetchAllSettings() {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
allSettings.value = await settingsService.getAllSettings()
|
allSettings.value = await settingsService.getAllSettings()
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to fetch settings', rethrow: false })
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch settings'
|
|
||||||
console.error('Error fetching settings:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSetting(setting: { setting_key: string; setting_value: string; description?: string }) {
|
async function createSetting(setting: { setting_key: string; setting_value: string; description?: string }) {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
const newSetting = await settingsService.createSetting(setting)
|
const newSetting = await settingsService.createSetting(setting)
|
||||||
allSettings.value.push(newSetting)
|
allSettings.value.push(newSetting)
|
||||||
return newSetting
|
return newSetting
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to create setting' })
|
||||||
error.value = err.response?.data?.detail || 'Failed to create setting'
|
|
||||||
console.error('Error creating setting:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSetting(settingKey: string, update: { setting_value: string; description?: string }) {
|
async function updateSetting(settingKey: string, update: { setting_value: string; description?: string }) {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
const updatedSetting = await settingsService.updateSetting(settingKey, update)
|
const updatedSetting = await settingsService.updateSetting(settingKey, update)
|
||||||
const index = allSettings.value.findIndex(s => s.setting_key === settingKey)
|
const index = allSettings.value.findIndex(s => s.setting_key === settingKey)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
allSettings.value[index] = updatedSetting
|
allSettings.value[index] = updatedSetting
|
||||||
}
|
}
|
||||||
return updatedSetting
|
return updatedSetting
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to update setting' })
|
||||||
error.value = err.response?.data?.detail || 'Failed to update setting'
|
|
||||||
console.error('Error updating setting:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSetting(settingKey: string) {
|
async function deleteSetting(settingKey: string) {
|
||||||
try {
|
return run(async () => {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
await settingsService.deleteSetting(settingKey)
|
await settingsService.deleteSetting(settingKey)
|
||||||
allSettings.value = allSettings.value.filter(s => s.setting_key !== settingKey)
|
allSettings.value = allSettings.value.filter(s => s.setting_key !== settingKey)
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to delete setting' })
|
||||||
error.value = err.response?.data?.detail || 'Failed to delete setting'
|
|
||||||
console.error('Error deleting setting:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearError() {
|
function clearError() {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const useTaskStatusesStore = defineStore('taskStatuses', () => {
|
|||||||
const loading = ref<Set<number>>(new Set())
|
const loading = ref<Set<number>>(new Set())
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// In-flight request de-dup: concurrent callers for the same project share one promise
|
||||||
|
const inFlightRequests = new Map<number, Promise<AllTaskStatusesResponse>>()
|
||||||
|
|
||||||
// Cache duration: 5 minutes
|
// Cache duration: 5 minutes
|
||||||
const CACHE_DURATION = 5 * 60 * 1000
|
const CACHE_DURATION = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -92,51 +95,39 @@ export const useTaskStatusesStore = defineStore('taskStatuses', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already loading
|
// Share the in-flight request with any concurrent callers instead of re-fetching
|
||||||
if (loading.value.has(projectId)) {
|
const existing = inFlightRequests.get(projectId)
|
||||||
// Wait for existing request to complete
|
if (existing) {
|
||||||
return new Promise((resolve, reject) => {
|
return existing
|
||||||
const checkInterval = setInterval(() => {
|
|
||||||
if (!loading.value.has(projectId)) {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
const cached = getProjectStatuses.value(projectId)
|
|
||||||
if (cached) {
|
|
||||||
resolve(cached)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to load task statuses'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
// Timeout after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
reject(new Error('Timeout waiting for task statuses'))
|
|
||||||
}, 10000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value.add(projectId)
|
loading.value.add(projectId)
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
const request = (async () => {
|
||||||
const data = await customTaskStatusService.getAllStatuses(projectId)
|
try {
|
||||||
|
const data = await customTaskStatusService.getAllStatuses(projectId)
|
||||||
// Cache the result
|
|
||||||
projectStatuses.value.set(projectId, {
|
|
||||||
projectId,
|
|
||||||
data,
|
|
||||||
lastFetched: Date.now()
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
// Cache the result
|
||||||
} catch (err: any) {
|
projectStatuses.value.set(projectId, {
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch task statuses'
|
projectId,
|
||||||
console.error('Error fetching task statuses:', err)
|
data,
|
||||||
throw err
|
lastFetched: Date.now()
|
||||||
} finally {
|
})
|
||||||
loading.value.delete(projectId)
|
|
||||||
}
|
return data
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to fetch task statuses'
|
||||||
|
console.error('Error fetching task statuses:', err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value.delete(projectId)
|
||||||
|
inFlightRequests.delete(projectId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inFlightRequests.set(projectId, request)
|
||||||
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache for a project (useful after creating/updating/deleting statuses)
|
// Invalidate cache for a project (useful after creating/updating/deleting statuses)
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { ref, computed } from 'vue'
|
|||||||
import { taskService, type Task, type TaskListItem } from '@/services/task'
|
import { taskService, type Task, type TaskListItem } from '@/services/task'
|
||||||
import { shotService, type Shot } from '@/services/shot'
|
import { shotService, type Shot } from '@/services/shot'
|
||||||
import { assetService, type Asset } from '@/services/asset'
|
import { assetService, type Asset } from '@/services/asset'
|
||||||
|
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||||
|
|
||||||
export const useTasksStore = defineStore('tasks', () => {
|
export const useTasksStore = defineStore('tasks', () => {
|
||||||
const tasks = ref<TaskListItem[]>([])
|
const tasks = ref<TaskListItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
const { run } = useAsyncAction({ isLoading: loading, error })
|
||||||
|
|
||||||
// Computed properties maintain existing store interface
|
// Computed properties maintain existing store interface
|
||||||
const myTasks = computed(() => {
|
const myTasks = computed(() => {
|
||||||
@@ -48,9 +50,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
|||||||
status?: string
|
status?: string
|
||||||
taskType?: string
|
taskType?: string
|
||||||
}) {
|
}) {
|
||||||
loading.value = true
|
return run(async () => {
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
// Always use optimized approach when projectId is available
|
// Always use optimized approach when projectId is available
|
||||||
if (filters?.projectId) {
|
if (filters?.projectId) {
|
||||||
// Use optimized approach: get both shots and assets with embedded task data
|
// Use optimized approach: get both shots and assets with embedded task data
|
||||||
@@ -62,7 +62,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
|||||||
// Extract tasks from embedded data in shots and assets
|
// Extract tasks from embedded data in shots and assets
|
||||||
const shotTasks = extractTasksFromShots(shots)
|
const shotTasks = extractTasksFromShots(shots)
|
||||||
const assetTasks = extractTasksFromAssets(assets)
|
const assetTasks = extractTasksFromAssets(assets)
|
||||||
|
|
||||||
// Combine all tasks
|
// Combine all tasks
|
||||||
let allTasks = [...shotTasks, ...assetTasks]
|
let allTasks = [...shotTasks, ...assetTasks]
|
||||||
|
|
||||||
@@ -81,15 +81,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to original task service only when no projectId is provided
|
// Fallback to original task service only when no projectId is provided
|
||||||
// This maintains backward compatibility for legacy usage
|
// This maintains backward compatibility for legacy usage
|
||||||
const response = await taskService.getTasks(filters)
|
tasks.value = await taskService.getTasks(filters)
|
||||||
tasks.value = response
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to fetch tasks', rethrow: false })
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch tasks'
|
|
||||||
console.error('Error fetching tasks:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTasksFromShots(shots: Shot[]): TaskListItem[] {
|
function extractTasksFromShots(shots: Shot[]): TaskListItem[] {
|
||||||
@@ -166,12 +160,10 @@ export const useTasksStore = defineStore('tasks', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTask(taskId: number) {
|
async function fetchTask(taskId: number) {
|
||||||
loading.value = true
|
return run(async () => {
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
const task = await taskService.getTask(taskId)
|
const task = await taskService.getTask(taskId)
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
|
|
||||||
// Update in tasks array if exists
|
// Update in tasks array if exists
|
||||||
const index = tasks.value.findIndex(t => t.id === taskId)
|
const index = tasks.value.findIndex(t => t.id === taskId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -219,15 +211,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
|||||||
}
|
}
|
||||||
tasks.value.push(taskListItem)
|
tasks.value.push(taskListItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
return task
|
return task
|
||||||
} catch (err: any) {
|
}, { errorMessage: 'Failed to fetch task' })
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch task'
|
|
||||||
console.error('Error fetching task:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateTaskStatus(taskId: number, status: string) {
|
async function updateTaskStatus(taskId: number, status: string) {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ import {
|
|||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
|
import { usePermission } from '@/composables/usePermission'
|
||||||
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
||||||
import EpisodeList from '@/components/episode/EpisodeList.vue'
|
import EpisodeList from '@/components/episode/EpisodeList.vue'
|
||||||
import EpisodeForm from '@/components/episode/EpisodeForm.vue'
|
import EpisodeForm from '@/components/episode/EpisodeForm.vue'
|
||||||
@@ -136,6 +137,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
@@ -150,17 +152,9 @@ const isSubmitting = ref(false)
|
|||||||
const selectedProjectId = ref<number | null>(null)
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canCreateEpisodes = computed(() => {
|
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteEpisodes = computed(() => {
|
const canDeleteEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableProjects = computed(() => {
|
const availableProjects = computed(() => {
|
||||||
return projectsStore.projects
|
return projectsStore.projects
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useProjectsStore } from "@/stores/projects";
|
import { useProjectsStore } from "@/stores/projects";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { usePermission } from "@/composables/usePermission";
|
||||||
import { useToast } from "@/components/ui/toast/use-toast";
|
import { useToast } from "@/components/ui/toast/use-toast";
|
||||||
import TechnicalSpecsManager from "@/components/project/TechnicalSpecsManager.vue";
|
import TechnicalSpecsManager from "@/components/project/TechnicalSpecsManager.vue";
|
||||||
import ProjectEditForm from "@/components/project/ProjectEditForm.vue";
|
import ProjectEditForm from "@/components/project/ProjectEditForm.vue";
|
||||||
@@ -201,6 +202,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const { isCoordinatorOrAdmin } = usePermission();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -236,10 +238,7 @@ const userDepartment = computed(() => {
|
|||||||
return member?.department_role;
|
return member?.department_role;
|
||||||
});
|
});
|
||||||
|
|
||||||
const canManageProject = computed(() => {
|
const canManageProject = computed(() => isCoordinatorOrAdmin.value);
|
||||||
if (!authStore.user) return false;
|
|
||||||
return authStore.user.role === 'coordinator' || authStore.user.is_admin;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadProject = async () => {
|
const loadProject = async () => {
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { usePermission } from '@/composables/usePermission'
|
||||||
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
|
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
|
||||||
import type { Project } from '@/stores/projects'
|
import type { Project } from '@/stores/projects'
|
||||||
import { apiClient } from '@/services/api'
|
import { apiClient } from '@/services/api'
|
||||||
@@ -414,7 +414,7 @@ import { apiClient } from '@/services/api'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
const authStore = useAuthStore()
|
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -456,15 +456,9 @@ const projectForm = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canCreateProjects = computed(() => {
|
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value)
|
||||||
const userRole = authStore.userRole
|
|
||||||
const isAdmin = authStore.user?.is_admin
|
|
||||||
return userRole === 'coordinator' || isAdmin
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteProjects = computed(() => {
|
const canDeleteProjects = computed(() => isAdmin.value)
|
||||||
return authStore.user?.is_admin
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
let filtered = projectsStore.projects
|
let filtered = projectsStore.projects
|
||||||
@@ -499,6 +493,11 @@ const isDateRangeValid = computed(() => {
|
|||||||
// Methods
|
// Methods
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
await projectsStore.fetchProjects()
|
await projectsStore.fetchProjects()
|
||||||
|
// thumbnailBlobUrls is local to this component instance and starts empty on every
|
||||||
|
// mount, so thumbnails must be (re)loaded after every fetch — not just when the
|
||||||
|
// store's project list actually changes (the store persists across navigations,
|
||||||
|
// so re-fetching the same projects wouldn't otherwise re-trigger loading here).
|
||||||
|
loadAllThumbnails()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectProject = (project: Project) => {
|
const selectProject = (project: Project) => {
|
||||||
@@ -764,11 +763,6 @@ const getProjectInitials = (name: string) => {
|
|||||||
return name.substring(0, 2).toUpperCase()
|
return name.substring(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for projects changes to load thumbnails
|
|
||||||
watch(() => projectsStore.projects, () => {
|
|
||||||
loadAllThumbnails()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch for start date changes to auto-update end date for new projects
|
// Watch for start date changes to auto-update end date for new projects
|
||||||
watch(() => projectForm.value.start_date, (newStartDate) => {
|
watch(() => projectForm.value.start_date, (newStartDate) => {
|
||||||
// Only auto-update end date for new projects (not when editing)
|
// Only auto-update end date for new projects (not when editing)
|
||||||
|
|||||||
+47
-34
@@ -46,43 +46,56 @@ Derived from `frontend_report.md`. Checklist form for tracking progress — chec
|
|||||||
- `GET /assets/{id}` never returned `task_details` (schema didn't even declare the field) — unlike `GET /shots/{id}`, which already did. Broke the asset detail panel's task list and, transitively, every new create-task/note/reference/version feature. Fixed in `backend/schemas/asset.py` + `backend/routers/assets.py`.
|
- `GET /assets/{id}` never returned `task_details` (schema didn't even declare the field) — unlike `GET /shots/{id}`, which already did. Broke the asset detail panel's task list and, transitively, every new create-task/note/reference/version feature. Fixed in `backend/schemas/asset.py` + `backend/routers/assets.py`.
|
||||||
- `TaskBrowser.vue`'s `handleRowClick` is a no-op by design (single click reserved for selection) — the new row-actions menu's "View Details"/"Reassign" needed to emit `row-double-click` instead, which is what actually opens the panel.
|
- `TaskBrowser.vue`'s `handleRowClick` is a no-op by design (single click reserved for selection) — the new row-actions menu's "View Details"/"Reassign" needed to emit `row-double-click` instead, which is what actually opens the panel.
|
||||||
|
|
||||||
## Phase 3 — Unify controls
|
## Phase 3 — Unify controls ✅ (done)
|
||||||
|
|
||||||
- [ ] Extract `ColumnToggleList` component (built on `DropdownMenuCheckboxItem`, not hand-rolled div+Check) and point at it from:
|
- [x] Extract `ColumnToggleList` component and point at it from:
|
||||||
- [ ] `components/ui/sidebar/SidebarColumnSwitch.vue`
|
- [x] `components/ui/sidebar/SidebarColumnSwitch.vue`
|
||||||
- [ ] `components/shot/ShotTableToolbar.vue`
|
- [x] `components/shot/ShotTableToolbar.vue`
|
||||||
- [ ] `components/asset/AssetTableToolbar.vue`
|
- [x] `components/asset/AssetTableToolbar.vue`
|
||||||
- [ ] `components/task/TaskTableToolbar.vue`
|
- [x] `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
|
Built on a new `CheckableCommandItem` primitive (`Command`/`CommandItem` wrapping a decorative `Checkbox`) instead of `DropdownMenuCheckboxItem` — keeps `CommandInput` search (needed for task-type columns) and avoids the ARIA double-toggle bug already found and fixed in the dead `ShotColumnVisibilityControl.vue`.
|
||||||
|
- [x] Shared toolbar pieces extracted as separate composable pieces rather than one monolithic `EntityTableToolbar` (scope decision, approved before implementation: shot/asset/task toolbars diverge enough — shot's 3-way view mode + bulk-create, asset's thumbnail toggle, task's different filter set — that a config-driven mega-component would be a leaky abstraction): `useDebouncedSearch` composable, `DetailPanelToggleButton.vue`, `ClearFiltersButton.vue`, `SegmentedToggle.vue`. All three toolbars now compose these instead of hand-rolled equivalents.
|
||||||
|
- [x] `SegmentedToggle` component — done (shot grid/list/table, asset grid/list, task all/shots/assets).
|
||||||
|
- [x] Delete confirmation — verified already consistent (Shot/Asset both use `Dialog`+`Alert`+impact-summary+type-to-confirm since Phase 1). Episode/Project deletes are simple client-side guards with no impact-summary fetch at all, so giving them one would be new feature work, not unification — left as-is (verification-only, no code change).
|
||||||
|
- [x] Swap hand-rolled checkbox-divs for `CheckableCommandItem` in:
|
||||||
|
- [x] `SidebarColumnSwitch.vue`
|
||||||
|
- [x] `ShotTableToolbar.vue`
|
||||||
|
- [x] `AssetTableToolbar.vue`
|
||||||
|
- [x] `TaskTableToolbar.vue`
|
||||||
|
- [x] `ShotTaskStatusFilter.vue` / `asset/TaskStatusFilter.vue` — merged into one `components/shared/TaskStatusFilter.vue`
|
||||||
|
- [x] Replace ad hoc `size="sm" class="h-8 w-8 p-0"` with `size="icon-sm"` — 24 occurrences across toolbars, detail panels, row-action triggers, and columns.ts render functions
|
||||||
|
- [x] CTA convention: dense table toolbars (Shot/Asset create buttons) stay icon-only but now have the same `title` tooltip every sibling icon button already had; full-page views (Project/Episode "New X") standardized wording between header and empty-state instances
|
||||||
|
- [x] Standardize row-actions/bulk-actions on `DropdownMenu`/`DropdownMenuItem` — `TaskBulkActionsMenu.vue`'s "Assign To" section now mirrors "Set Status"'s existing submenu instead of a hand-rolled `<button>` list
|
||||||
|
- [x] Extract shared detail-panel shell for `ShotDetailPanel.vue`, `AssetDetailPanel.vue`, `TaskDetailPanel.vue`: `DetailPanelOverlay.vue` (slide-in transition + mobile `Sheet`), `DetailPanelHeader.vue`, `DetailPanelLoading.vue`, `DetailPanelError.vue`. The `Tabs` scaffold was deliberately *not* unified — tab sets genuinely differ per domain (5 vs. 3 vs. 4 tabs) and forcing a shared config would be the kind of premature abstraction this phase was meant to avoid. Added the previously-missing error state to `TaskDetailPanel.vue` (it had no `error` ref or error branch at all — a failed load rendered nothing).
|
||||||
|
|
||||||
- [ ] Add a shared `useAsyncAction`-style composable (`isLoading`/`error`/`run(fn)`) and adopt it across `stores/*.ts` to cut repeated try/catch/finally boilerplate
|
**Bugs found and fixed during verification (approved mid-implementation):**
|
||||||
- [ ] Replace `stores/taskStatuses.ts`'s polling-based in-flight request de-dup (100ms `setInterval` loop, lines 96-117) with promise memoization
|
- Shot's episode filter and Asset's category filter had silently been left on the old hand-rolled checkbox pattern (visually identical to `CheckableCommandItem`, so it wasn't caught by screenshots alone) — converted along with everything else.
|
||||||
- [ ] 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)
|
- `ShotDetailPanel.vue`'s Tasks list built `assigned_user_name: undefined // Will be resolved if needed` and never actually resolved it, so every task always showed "Unassigned" regardless of real assignment; and its task-status `Badge` used a hardcoded 5-value switch instead of the same `TaskStatusBadge`/task-statuses-store the shot table uses, so colors/labels (including custom per-project statuses) could drift from the table. Fixed both, and added an avatar next to the assignee name to match the table's assignment control.
|
||||||
- [ ] 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`
|
## Phase 4 — Architecture & tooling investment ✅ (done, scoped)
|
||||||
- [ ] 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
|
Scope was narrowed with the user before implementation: this pass covers the mechanical/low-risk items with a demonstrated, concrete problem behind each. Several checklist items below turned out to be either genuine new feature work, carry real regression risk on large already-working files, or would touch a very wide surface for a purely organizational payoff — those are called out as **deferred**, not silently dropped.
|
||||||
- [ ] 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)
|
- [x] Fix N+1 project-member fetch: new `stores/projectMembers.ts` (same Map+TTL+in-flight-promise-dedup pattern as `taskStatuses.ts`), both `EditableTaskStatus.vue` variants now use it instead of independently calling `projectService.getProjectMembers` per row × task-type column. Verified via network interception: one call per project load, not one per cell.
|
||||||
- [ ] 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`)
|
- [x] Replace `stores/taskStatuses.ts`'s polling-based in-flight de-dup (100ms `setInterval` loop) with a stored in-flight `Promise` per project ID — same pattern applied to the new `projectMembers` store.
|
||||||
- [ ] Centralize a `usePermission()`/`can()` composable to consolidate role/admin checks currently duplicated across ~20 components outside the router guard
|
- [x] Add `markRaw()` around icon components stored in reactive state — `stores/projects.ts` `assignProjectIcon` and the static `allProjectsView.icon`.
|
||||||
- [ ] 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
|
- [x] Fix `views/ProjectsView.vue`'s deep watcher on the full projects array (only existed to trigger thumbnail loading) — replaced with a shallow watch keyed on project IDs. The other ~10 `deep: true` watchers found elsewhere were audited but not changed — each needs its own read to judge triviality, out of scope for this pass.
|
||||||
- [ ] Move ad hoc domain types out of `services/*.ts` into `types/` for discoverability (currently only `auth.ts`, `notification.ts`, `activity.ts` live in `types/`)
|
- [x] Add a shared `useAsyncAction` composable (`{isLoading, error}` in, `run(fn, {errorMessage, rethrow})` out) — applied only where the shape was already a clean match with no divergence: `auth.ts` (`login`/`register`), `episodes.ts` (all 5 actions), `settings.ts` (all 6), and the plain fetch actions in `projects.ts` (5) and `tasks.ts` (`fetchTasks`/`fetchTask`). Left untouched: `user.ts`'s mutations (no `isLoading` flag — adding one would be new behavior), `notifications.ts` (chains a second async call), `assets.ts`'s optimistic updaters (different shape, see below), `auth.ts:logout` (intentionally swallows and always clears state).
|
||||||
|
- [x] Extract `assets.ts`'s shared per-asset optimistic-update helper (`snapshotAssetTaskStatus`/`applyAssetTaskStatus`/`rollbackAssetTaskStatus`) used by both `updateTaskStatus` and `bulkUpdateTaskStatus` — the two outer functions stay separate since they call genuinely different API endpoints (single-task vs. a distinct bulk endpoint with its own response shape); only the local-state snapshot/apply/rollback was actually duplicated.
|
||||||
|
- [x] Add `usePermission()` composable (`isAdmin`, `isCoordinatorOrAdmin`) and apply to the ~15 confirmed sites duplicating the `is_admin || role === 'coordinator'` check (`ShotDetailPanel.vue`, `TaskDetailPanel.vue`, `TaskList.vue`, `EpisodesView.vue`, `EpisodeDropdown.vue`, `ProjectsView.vue`, `ProjectSettingsView.vue`, `TechnicalSpecsPanel.vue`, `TechnicalSpecsManager.vue`, `AppSidebar.vue`, `ProjectSwitcher.vue`). Left untouched: `UserMenu.vue`'s three-way variant (adds a `developer` role check) and `NoteItem.vue`'s ownership check — genuinely different logic.
|
||||||
|
- [x] Add toast-on-error for the silent secondary loads in `ShotBrowser.vue` (episodes/task-types/task-statuses/project-context) and the router's auth-init failure path. In fixing the latter, found and fixed a real bug: `router/index.ts`'s `try/catch` around `authStore.initializeAuth()` was dead code — that function never throws (it swallows its own errors and calls `logout()` internally) — so the catch could never fire. Replaced with a check of `authStore.isAuthenticated` after the call, which correctly detects the failure. **`stores/notifications.ts:fetchStats` was deliberately left as a silent `console.error`**: it's polled every 30s by `startPolling()`, and a destructive toast firing on every poll during a network blip would be worse UX than the silent failure it replaces — this deviates from the original checklist wording, flagging rather than silently skipping.
|
||||||
|
|
||||||
|
**Bugs found and fixed during verification (approved mid-implementation):**
|
||||||
|
- `ShotDetailPanel.vue` had a dead `authStore.isAdmin` check sitting right next to the ones consolidated into `usePermission()` — swapped to the composable's `isAdmin` for consistency while already in that file.
|
||||||
|
- Found (not fixed — backend, out of scope): `GET /settings/upload-limit` 404s because `backend/routers/settings.py` declares its own `/settings` prefix and `main.py` adds another, so the real path is `/settings/settings/upload-limit`. Confirmed via direct backend curl; unrelated to any frontend change in this phase.
|
||||||
|
|
||||||
|
**Deferred** (documented, not implemented — see the approved plan for full rationale):
|
||||||
|
- [ ] Extend TTL caching to `stores/assets.ts`, `stores/tasks.ts`, `stores/projects.ts`, `stores/episodes.ts` — no demonstrated redundant-fetch problem for these stores (unlike task statuses/project members), and caching list data in a multi-user live-editing tool risks staleness bugs.
|
||||||
|
- [ ] Add table virtualization (`@tanstack/vue-virtual`) to `ShotsDataTable.vue`, `AssetsDataTable.vue`, `TasksDataTable.vue` — real feature work with design decisions that interact with the existing frozen-column two-pane scroll-sync layout, not a mechanical cleanup.
|
||||||
|
- [ ] Introduce a shared `EmptyState` component and standardize on `Skeleton` for loading states — both already exist (`ui/empty/EmptyState.vue`, `ui/skeleton/Skeleton.vue`) and are cheap to reach for going forward, but a full sweep of ~58 ad hoc spinner sites is out of scope for this pass.
|
||||||
|
- [ ] Split `views/project/ShotDetailView.vue` (1658 lines) / `views/admin/DeletedItemsManagementView.vue` (1279 lines) and extract a shared "entity browser" composable for `ShotBrowser.vue`/`AssetBrowser.vue` — both files are live and wired, and the overlap between Shot/Asset browsers is only partial (episode vs. category filters, store vs. local state). Bigger regression risk than the payoff justifies bundling here.
|
||||||
|
- [ ] Move domain types (`Shot`/`Asset`/`Task`/`Project`/`Episode`) out of `services/*.ts` into `types/` — mechanical but touches every import site for a purely organizational win; fix opportunistically when a file is substantially touched anyway, same approach as the `ApiError` helper below.
|
||||||
|
|
||||||
## Phase 5 — Testing & quality gates
|
## Phase 5 — Testing & quality gates
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user