Phase 4: Architecture & tooling investment
Fixes a real N+1 API-call bug (per-cell project-member fetches in shot/asset task-status cells, now a shared cached store), replaces a polling-based in-flight request de-dup with promise memoization, adds markRaw() around icon components in reactive state, and fixes a deep watcher that re-scanned the whole projects array just to trigger thumbnail loading. Introduces useAsyncAction and usePermission composables to cut duplicated store boilerplate and duplicated role/admin checks, applied only where the existing code was a clean match rather than forced onto everything. Extracts assets.ts's shared per-asset optimistic-update/rollback helper without merging the single and bulk API paths, which hit genuinely different endpoints. Adds toast-on-error for previously-silent secondary loads and fixes a dead try/catch in the router's auth-init guard along the way. Also fixes a thumbnail-loading regression introduced earlier in this same pass: the new ID-keyed watcher never fired on a repeat visit to the Projects page when the store already held the same project list, so thumbnails (local component state, reset per mount) silently stopped loading after the first visit. Replaced the watcher with a direct call after each fetch. Table virtualization, splitting the two largest view files, broad store caching, and moving domain types into types/ were scoped out as separate, higher-risk follow-ups (documented in frontend_tasks.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
|
||||
@@ -202,11 +203,12 @@ const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const projectMembersStore = useProjectMembersStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filtered project members based on search query
|
||||
@@ -306,17 +308,12 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
// Load project members (shared cache across all EditableTaskStatus instances for this project)
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
} finally {
|
||||
isLoadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,10 +397,9 @@ onMounted(() => {
|
||||
loadProjectMembers()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
// Refetch statuses and members when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
loadProjectMembers()
|
||||
})
|
||||
</script>
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
@@ -100,7 +100,7 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const episodes = ref<Episode[]>([])
|
||||
@@ -108,11 +108,7 @@ const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const canCreateEpisodes = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const sortedEpisodes = computed(() => {
|
||||
return [...episodes.value].sort((a, b) => {
|
||||
|
||||
@@ -123,10 +123,12 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import ProjectSwitcher from './ProjectSwitcher.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { state } = useSidebar()
|
||||
const route = useRoute()
|
||||
const user = computed(() => authStore.user)
|
||||
@@ -150,7 +152,7 @@ const navigationItems = computed(() => {
|
||||
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
||||
]
|
||||
|
||||
if (userRole.value === 'coordinator' || authStore.isAdmin) {
|
||||
if (isCoordinatorOrAdmin.value) {
|
||||
baseItems.push(
|
||||
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
||||
{ title: 'Team', url: '/users', icon: Users }
|
||||
|
||||
@@ -135,11 +135,13 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useProjectsStore } from "@/stores/projects";
|
||||
import { usePermission } from "@/composables/usePermission";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useSidebar();
|
||||
const authStore = useAuthStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const { isCoordinatorOrAdmin } = usePermission();
|
||||
|
||||
// Get projects and active project from store
|
||||
const projects = computed(() => projectsStore.availableProjects);
|
||||
@@ -148,10 +150,7 @@ const isLoading = computed(() => projectsStore.isLoading);
|
||||
const error = computed(() => projectsStore.error);
|
||||
|
||||
// Check if user can create projects
|
||||
const canCreateProjects = computed(() => {
|
||||
const user = authStore.user;
|
||||
return user?.is_admin || user?.role === "coordinator";
|
||||
});
|
||||
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value);
|
||||
|
||||
const setActiveProject = (project: any) => {
|
||||
projectsStore.setActiveProject(project);
|
||||
|
||||
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import { notificationService } from '@/services/notifications'
|
||||
@@ -97,7 +97,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
@@ -108,11 +108,7 @@ const isEditing = ref(false)
|
||||
const showNotification = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
// Methods
|
||||
const loadSpecs = async () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
||||
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
||||
@@ -73,7 +73,7 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// State
|
||||
const isOpen = ref(props.defaultOpen || false)
|
||||
@@ -82,11 +82,7 @@ const error = ref<string | null>(null)
|
||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canEdit = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const hasSpecs = computed(() => {
|
||||
if (!specs.value) return false
|
||||
|
||||
@@ -169,8 +169,9 @@ import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -198,11 +199,12 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const projectMembersStore = useProjectMembersStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Filtered project members based on search query
|
||||
@@ -306,27 +308,18 @@ const fetchStatuses = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
// Load project members (shared cache across all EditableTaskStatus instances for this project)
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
console.log('Loading project members for project:', props.projectId)
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
console.log('Loaded project members:', projectMembers.value)
|
||||
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
} finally {
|
||||
isLoadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure members are loaded when popover is about to open
|
||||
const ensureMembersLoaded = () => {
|
||||
console.log('Ensuring project members are loaded')
|
||||
if (projectMembers.value.length === 0) {
|
||||
console.log('Loading project members on button click')
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
@@ -407,7 +400,6 @@ onMounted(() => {
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
loadProjectMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -449,44 +449,64 @@ const loadShots = async () => {
|
||||
|
||||
const loadEpisodes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
|
||||
try {
|
||||
const data = await episodeService.getEpisodes(props.projectId)
|
||||
episodes.value = data
|
||||
} catch (err) {
|
||||
console.error('Failed to load episodes:', err)
|
||||
toast({
|
||||
title: 'Failed to load episodes',
|
||||
description: err instanceof Error ? err.message : 'Episode filtering may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskTypes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
|
||||
try {
|
||||
const data = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||
allTaskTypes.value = data.shot_task_types || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load task types:', err)
|
||||
toast({
|
||||
title: 'Failed to load task types',
|
||||
description: err instanceof Error ? err.message : 'Task columns may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load task statuses:', err)
|
||||
toast({
|
||||
title: 'Failed to load task statuses',
|
||||
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadProjectContext = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
|
||||
try {
|
||||
const project = await projectService.getProject(props.projectId)
|
||||
projectContext.value = project
|
||||
} catch (err) {
|
||||
console.error('Failed to load project context:', err)
|
||||
toast({
|
||||
title: 'Failed to load project context',
|
||||
description: err instanceof Error ? err.message : 'Some shot validation checks may be unavailable',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -350,9 +350,9 @@ import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
interface Task extends TaskStatusInfo {
|
||||
@@ -384,9 +384,9 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const shot = ref<Shot | null>(null)
|
||||
@@ -432,25 +432,17 @@ const taskStatusCounts = computed(() => {
|
||||
|
||||
|
||||
|
||||
const canCreateTask = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canCreateNote = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canLinkAssets = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canUploadReferences = computed(() => {
|
||||
return true // All users can upload references
|
||||
})
|
||||
|
||||
const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
|
||||
@@ -320,6 +320,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
const task = ref<Task | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -350,10 +351,7 @@ const canSubmitWork = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const canReassign = computed(() => {
|
||||
if (!authStore.user) return false
|
||||
return authStore.user.is_admin || authStore.user.role === 'coordinator'
|
||||
})
|
||||
const canReassign = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
async function loadTask() {
|
||||
loading.value = true
|
||||
@@ -478,6 +476,7 @@ function getUserInitials(member: ProjectMember): string {
|
||||
}
|
||||
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -344,7 +344,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const tasksStore = useTasksStore()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { toast } = useToast()
|
||||
|
||||
const searchQuery = ref('')
|
||||
@@ -361,9 +361,7 @@ const assignmentDepartmentFilter = ref('all')
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const assigningTask = ref(false)
|
||||
|
||||
const canAssignTasks = computed(() => {
|
||||
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
|
||||
})
|
||||
const canAssignTasks = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const showDepartmentFilter = computed(() => {
|
||||
return canAssignTasks.value
|
||||
|
||||
@@ -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 type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { toast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Public routes
|
||||
@@ -192,12 +193,17 @@ const router = createRouter({
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
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) {
|
||||
try {
|
||||
await authStore.initializeAuth()
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error)
|
||||
await authStore.initializeAuth()
|
||||
if (!authStore.isAuthenticated) {
|
||||
toast({
|
||||
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 { 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', () => {
|
||||
// State
|
||||
const assets = ref<Asset[]>([])
|
||||
@@ -159,25 +207,11 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
if (assetIndex === -1) return
|
||||
|
||||
const asset = assets.value[assetIndex]
|
||||
|
||||
// Store original status for rollback
|
||||
const originalStatus = asset.task_status?.[taskType]
|
||||
const originalTaskDetail = asset.task_details?.find(task => task.task_type === taskType)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Store original status for rollback, then apply the optimistic update
|
||||
const snapshot = snapshotAssetTaskStatus(asset, taskType)
|
||||
applyAssetTaskStatus(asset, taskType, newStatus)
|
||||
|
||||
// Update the current asset if it's the same
|
||||
if (currentAsset.value?.id === assetId) {
|
||||
currentAsset.value = { ...asset }
|
||||
@@ -216,19 +250,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback optimistic update on error
|
||||
if (asset.task_status && originalStatus !== undefined) {
|
||||
asset.task_status[taskType] = originalStatus
|
||||
}
|
||||
|
||||
if (originalTaskDetail && originalTaskDetailStatus !== undefined) {
|
||||
originalTaskDetail.status = originalTaskDetailStatus
|
||||
}
|
||||
|
||||
rollbackAssetTaskStatus(asset, taskType, snapshot)
|
||||
|
||||
// Update the current asset if it's the same
|
||||
if (currentAsset.value?.id === assetId) {
|
||||
currentAsset.value = { ...asset }
|
||||
}
|
||||
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -237,10 +265,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
if (assetIds.length === 0) return
|
||||
|
||||
// Store original states for rollback
|
||||
const originalStates = new Map<number, {
|
||||
taskStatus?: TaskStatus
|
||||
taskDetail?: { status: TaskStatus; task_id?: number }
|
||||
}>()
|
||||
const originalStates = new Map<number, AssetTaskStatusSnapshot>()
|
||||
|
||||
// Optimistic updates
|
||||
for (const assetId of assetIds) {
|
||||
@@ -248,30 +273,10 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
if (assetIndex === -1) continue
|
||||
|
||||
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
|
||||
if (asset.task_status) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
originalStates.set(assetId, snapshotAssetTaskStatus(asset, taskType))
|
||||
applyAssetTaskStatus(asset, taskType, newStatus)
|
||||
|
||||
// Update current asset if it's the same
|
||||
if (currentAsset.value?.id === assetId) {
|
||||
currentAsset.value = { ...asset }
|
||||
@@ -325,32 +330,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
if (assetIndex === -1) continue
|
||||
|
||||
const asset = assets.value[assetIndex]
|
||||
const originalState = originalStates.get(assetId)
|
||||
|
||||
if (originalState) {
|
||||
// Rollback task status
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
const snapshot = originalStates.get(assetId)
|
||||
|
||||
if (snapshot) {
|
||||
rollbackAssetTaskStatus(asset, taskType, snapshot, { restoreTaskId: true })
|
||||
}
|
||||
|
||||
|
||||
// Update current asset if it's the same
|
||||
if (currentAsset.value?.id === assetId) {
|
||||
currentAsset.value = { ...asset }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authService } from '@/services/auth'
|
||||
import type { User, LoginCredentials, RegisterData, LoginResponse } from '@/types/auth'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
@@ -10,6 +11,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { run } = useAsyncAction({ isLoading, error })
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||
@@ -18,43 +20,24 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
return run(async () => {
|
||||
const response = await authService.login(credentials)
|
||||
|
||||
|
||||
// Store tokens
|
||||
accessToken.value = response.access_token
|
||||
refreshToken.value = response.refresh_token
|
||||
localStorage.setItem('access_token', response.access_token)
|
||||
localStorage.setItem('refresh_token', response.refresh_token)
|
||||
|
||||
|
||||
// Get user profile
|
||||
await getCurrentUser()
|
||||
|
||||
|
||||
return response
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Login failed' })
|
||||
}
|
||||
|
||||
const register = async (data: RegisterData) => {
|
||||
try {
|
||||
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
|
||||
}
|
||||
return run(() => authService.register(data), { errorMessage: 'Registration failed' })
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
export const useEpisodesStore = defineStore('episodes', () => {
|
||||
// State
|
||||
@@ -8,6 +9,7 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
||||
const currentEpisode = ref<Episode | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { run } = useAsyncAction({ isLoading, error })
|
||||
|
||||
// Getters
|
||||
const getEpisodeById = computed(() => {
|
||||
@@ -24,30 +26,17 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
||||
|
||||
// Actions
|
||||
const fetchEpisodes = async (projectId?: number) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
if (projectId) {
|
||||
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
|
||||
}
|
||||
return run(async () => {
|
||||
episodes.value = projectId
|
||||
? await episodeService.getProjectEpisodes(projectId)
|
||||
: await episodeService.getEpisodes()
|
||||
}, { errorMessage: 'Failed to fetch episodes' })
|
||||
}
|
||||
|
||||
const fetchEpisode = async (episodeId: number) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
return run(async () => {
|
||||
const episode = await episodeService.getEpisode(episodeId)
|
||||
|
||||
|
||||
// Update the episode in the list if it exists
|
||||
const index = episodes.value.findIndex(e => e.id === episodeId)
|
||||
if (index !== -1) {
|
||||
@@ -55,81 +44,51 @@ export const useEpisodesStore = defineStore('episodes', () => {
|
||||
} else {
|
||||
episodes.value.push(episode)
|
||||
}
|
||||
|
||||
|
||||
currentEpisode.value = episode
|
||||
return episode
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch episode'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch episode' })
|
||||
}
|
||||
|
||||
const createEpisode = async (projectId: number, episodeData: EpisodeCreate) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
return run(async () => {
|
||||
const newEpisode = await episodeService.createEpisode(projectId, episodeData)
|
||||
episodes.value.push(newEpisode)
|
||||
|
||||
return newEpisode
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to create episode'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to create episode' })
|
||||
}
|
||||
|
||||
const updateEpisode = async (episodeId: number, episodeData: EpisodeUpdate) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
return run(async () => {
|
||||
const updatedEpisode = await episodeService.updateEpisode(episodeId, episodeData)
|
||||
|
||||
|
||||
// Update the episode in the list
|
||||
const index = episodes.value.findIndex(e => e.id === episodeId)
|
||||
if (index !== -1) {
|
||||
episodes.value[index] = updatedEpisode
|
||||
}
|
||||
|
||||
|
||||
// Update current episode if it's the same
|
||||
if (currentEpisode.value?.id === episodeId) {
|
||||
currentEpisode.value = updatedEpisode
|
||||
}
|
||||
|
||||
|
||||
return updatedEpisode
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update episode'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to update episode' })
|
||||
}
|
||||
|
||||
const deleteEpisode = async (episodeId: number) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
return run(async () => {
|
||||
await episodeService.deleteEpisode(episodeId)
|
||||
|
||||
|
||||
// Remove the episode from the list
|
||||
episodes.value = episodes.value.filter(e => e.id !== episodeId)
|
||||
|
||||
|
||||
// Clear current episode if it's the deleted one
|
||||
if (currentEpisode.value?.id === episodeId) {
|
||||
currentEpisode.value = null
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete episode'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to delete episode' })
|
||||
}
|
||||
|
||||
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 { ref, computed } from 'vue'
|
||||
import { ref, computed, markRaw } from 'vue'
|
||||
import { Film, Palette, Zap, Folder } from 'lucide-vue-next'
|
||||
import { projectService, type Project as ProjectType, type ProjectCreate, type ProjectUpdate } from '@/services/project'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
export interface Project extends ProjectType {
|
||||
icon?: any
|
||||
@@ -13,13 +14,14 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const activeProject = ref<Project | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { run } = useAsyncAction({ isLoading, error })
|
||||
|
||||
// Getters
|
||||
const allProjectsView: Project = {
|
||||
id: 0,
|
||||
name: 'All Projects',
|
||||
status: 'planning',
|
||||
icon: Folder,
|
||||
icon: markRaw(Folder),
|
||||
description: 'View all projects',
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
@@ -83,105 +85,66 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { ...project, icon }
|
||||
return { ...project, icon: markRaw(icon) }
|
||||
}
|
||||
|
||||
// Actions
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const fetchedProjects = await projectService.getUserProjects()
|
||||
projects.value = fetchedProjects.map(assignProjectIcon)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch projects'
|
||||
console.error('Failed to fetch projects:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch projects', rethrow: false })
|
||||
}
|
||||
|
||||
const createProject = async (projectData: ProjectCreate) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const newProject = await projectService.createProject(projectData)
|
||||
const projectWithIcon = assignProjectIcon(newProject)
|
||||
projects.value.push(projectWithIcon)
|
||||
return projectWithIcon
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to create project'
|
||||
console.error('Failed to create project:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to create project' })
|
||||
}
|
||||
|
||||
const updateProject = async (id: number, updates: ProjectUpdate) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const updatedProject = await projectService.updateProject(id, updates)
|
||||
const projectWithIcon = assignProjectIcon(updatedProject)
|
||||
|
||||
|
||||
const index = projects.value.findIndex(p => p.id === id)
|
||||
if (index !== -1) {
|
||||
projects.value[index] = projectWithIcon
|
||||
|
||||
|
||||
// Update active project if it's the one being updated
|
||||
if (activeProject.value?.id === id) {
|
||||
activeProject.value = projectWithIcon
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return projectWithIcon
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update project'
|
||||
console.error('Failed to update project:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to update project' })
|
||||
}
|
||||
|
||||
const deleteProject = async (id: number) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
await projectService.deleteProject(id)
|
||||
|
||||
|
||||
const index = projects.value.findIndex(p => p.id === id)
|
||||
if (index !== -1) {
|
||||
projects.value.splice(index, 1)
|
||||
|
||||
|
||||
// If the removed project was active, switch to all projects view
|
||||
if (activeProject.value?.id === id) {
|
||||
activeProject.value = allProjectsView
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete project'
|
||||
console.error('Failed to delete project:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to delete project' })
|
||||
}
|
||||
|
||||
const getProject = async (id: number, includeMembers: boolean = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const project = await projectService.getProject(id, includeMembers)
|
||||
return assignProjectIcon(project)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch project'
|
||||
console.error('Failed to fetch project:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch project' })
|
||||
}
|
||||
|
||||
const setActiveProject = (project: Project) => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { settingsService, type UploadLimitResponse, type GlobalSetting } from '@/services/settings'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const uploadLimit = ref<UploadLimitResponse | null>(null)
|
||||
const allSettings = ref<GlobalSetting[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { run } = useAsyncAction({ isLoading: loading, error })
|
||||
|
||||
// Computed
|
||||
const uploadLimitMB = computed(() => uploadLimit.value?.upload_limit_mb || 1000)
|
||||
@@ -14,93 +16,47 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
// Actions
|
||||
async function fetchUploadLimit() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
uploadLimit.value = await settingsService.getUploadLimit()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch upload limit'
|
||||
console.error('Error fetching upload limit:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch upload limit', rethrow: false })
|
||||
}
|
||||
|
||||
async function updateUploadLimit(limitMB: number) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
uploadLimit.value = await settingsService.updateUploadLimit({ upload_limit_mb: limitMB })
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update upload limit'
|
||||
console.error('Error updating upload limit:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to update upload limit' })
|
||||
}
|
||||
|
||||
async function fetchAllSettings() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
allSettings.value = await settingsService.getAllSettings()
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch settings'
|
||||
console.error('Error fetching settings:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch settings', rethrow: false })
|
||||
}
|
||||
|
||||
async function createSetting(setting: { setting_key: string; setting_value: string; description?: string }) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const newSetting = await settingsService.createSetting(setting)
|
||||
allSettings.value.push(newSetting)
|
||||
return newSetting
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to create setting'
|
||||
console.error('Error creating setting:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to create setting' })
|
||||
}
|
||||
|
||||
async function updateSetting(settingKey: string, update: { setting_value: string; description?: string }) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
const updatedSetting = await settingsService.updateSetting(settingKey, update)
|
||||
const index = allSettings.value.findIndex(s => s.setting_key === settingKey)
|
||||
if (index !== -1) {
|
||||
allSettings.value[index] = updatedSetting
|
||||
}
|
||||
return updatedSetting
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update setting'
|
||||
console.error('Error updating setting:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to update setting' })
|
||||
}
|
||||
|
||||
async function deleteSetting(settingKey: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
return run(async () => {
|
||||
await settingsService.deleteSetting(settingKey)
|
||||
allSettings.value = allSettings.value.filter(s => s.setting_key !== settingKey)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete setting'
|
||||
console.error('Error deleting setting:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to delete setting' })
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
|
||||
@@ -14,6 +14,9 @@ export const useTaskStatusesStore = defineStore('taskStatuses', () => {
|
||||
const loading = ref<Set<number>>(new Set())
|
||||
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
|
||||
const CACHE_DURATION = 5 * 60 * 1000
|
||||
|
||||
@@ -92,51 +95,39 @@ export const useTaskStatusesStore = defineStore('taskStatuses', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already loading
|
||||
if (loading.value.has(projectId)) {
|
||||
// Wait for existing request to complete
|
||||
return new Promise((resolve, reject) => {
|
||||
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)
|
||||
})
|
||||
// Share the in-flight request with any concurrent callers instead of re-fetching
|
||||
const existing = inFlightRequests.get(projectId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
loading.value.add(projectId)
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await customTaskStatusService.getAllStatuses(projectId)
|
||||
|
||||
// Cache the result
|
||||
projectStatuses.value.set(projectId, {
|
||||
projectId,
|
||||
data,
|
||||
lastFetched: Date.now()
|
||||
})
|
||||
const request = (async () => {
|
||||
try {
|
||||
const data = await customTaskStatusService.getAllStatuses(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)
|
||||
}
|
||||
// Cache the result
|
||||
projectStatuses.value.set(projectId, {
|
||||
projectId,
|
||||
data,
|
||||
lastFetched: Date.now()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,12 +3,14 @@ import { ref, computed } from 'vue'
|
||||
import { taskService, type Task, type TaskListItem } from '@/services/task'
|
||||
import { shotService, type Shot } from '@/services/shot'
|
||||
import { assetService, type Asset } from '@/services/asset'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
|
||||
export const useTasksStore = defineStore('tasks', () => {
|
||||
const tasks = ref<TaskListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const { run } = useAsyncAction({ isLoading: loading, error })
|
||||
|
||||
// Computed properties maintain existing store interface
|
||||
const myTasks = computed(() => {
|
||||
@@ -48,9 +50,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
status?: string
|
||||
taskType?: string
|
||||
}) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return run(async () => {
|
||||
// Always use optimized approach when projectId is available
|
||||
if (filters?.projectId) {
|
||||
// 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
|
||||
const shotTasks = extractTasksFromShots(shots)
|
||||
const assetTasks = extractTasksFromAssets(assets)
|
||||
|
||||
|
||||
// Combine all tasks
|
||||
let allTasks = [...shotTasks, ...assetTasks]
|
||||
|
||||
@@ -81,15 +81,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
} else {
|
||||
// Fallback to original task service only when no projectId is provided
|
||||
// This maintains backward compatibility for legacy usage
|
||||
const response = await taskService.getTasks(filters)
|
||||
tasks.value = response
|
||||
tasks.value = await taskService.getTasks(filters)
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch tasks'
|
||||
console.error('Error fetching tasks:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch tasks', rethrow: false })
|
||||
}
|
||||
|
||||
function extractTasksFromShots(shots: Shot[]): TaskListItem[] {
|
||||
@@ -166,12 +160,10 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
}
|
||||
|
||||
async function fetchTask(taskId: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return run(async () => {
|
||||
const task = await taskService.getTask(taskId)
|
||||
selectedTask.value = task
|
||||
|
||||
|
||||
// Update in tasks array if exists
|
||||
const index = tasks.value.findIndex(t => t.id === taskId)
|
||||
if (index !== -1) {
|
||||
@@ -219,15 +211,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
}
|
||||
tasks.value.push(taskListItem)
|
||||
}
|
||||
|
||||
|
||||
return task
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch task'
|
||||
console.error('Error fetching task:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { errorMessage: 'Failed to fetch task' })
|
||||
}
|
||||
|
||||
async function updateTaskStatus(taskId: number, status: string) {
|
||||
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { episodeService, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/services/episode'
|
||||
import EpisodeList from '@/components/episode/EpisodeList.vue'
|
||||
import EpisodeForm from '@/components/episode/EpisodeForm.vue'
|
||||
@@ -136,6 +137,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
// Reactive state
|
||||
@@ -150,17 +152,9 @@ const isSubmitting = ref(false)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const canCreateEpisodes = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canCreateEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canDeleteEpisodes = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canDeleteEpisodes = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const availableProjects = computed(() => {
|
||||
return projectsStore.projects
|
||||
|
||||
@@ -184,6 +184,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useProjectsStore } from "@/stores/projects";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermission } from "@/composables/usePermission";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import TechnicalSpecsManager from "@/components/project/TechnicalSpecsManager.vue";
|
||||
import ProjectEditForm from "@/components/project/ProjectEditForm.vue";
|
||||
@@ -201,6 +202,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const projectsStore = useProjectsStore();
|
||||
const authStore = useAuthStore();
|
||||
const { isCoordinatorOrAdmin } = usePermission();
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
@@ -236,10 +238,7 @@ const userDepartment = computed(() => {
|
||||
return member?.department_role;
|
||||
});
|
||||
|
||||
const canManageProject = computed(() => {
|
||||
if (!authStore.user) return false;
|
||||
return authStore.user.role === 'coordinator' || authStore.user.is_admin;
|
||||
});
|
||||
const canManageProject = computed(() => isCoordinatorOrAdmin.value);
|
||||
|
||||
// Methods
|
||||
const loadProject = async () => {
|
||||
|
||||
@@ -406,7 +406,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
|
||||
import type { Project } from '@/stores/projects'
|
||||
import { apiClient } from '@/services/api'
|
||||
@@ -414,7 +414,7 @@ import { apiClient } from '@/services/api'
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const projectsStore = useProjectsStore()
|
||||
const authStore = useAuthStore()
|
||||
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
||||
|
||||
// Reactive state
|
||||
const searchQuery = ref('')
|
||||
@@ -456,15 +456,9 @@ const projectForm = ref({
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const canCreateProjects = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
const canCreateProjects = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canDeleteProjects = computed(() => {
|
||||
return authStore.user?.is_admin
|
||||
})
|
||||
const canDeleteProjects = computed(() => isAdmin.value)
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
let filtered = projectsStore.projects
|
||||
@@ -499,6 +493,11 @@ const isDateRangeValid = computed(() => {
|
||||
// Methods
|
||||
const loadProjects = async () => {
|
||||
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) => {
|
||||
@@ -764,11 +763,6 @@ const getProjectInitials = (name: string) => {
|
||||
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(() => projectForm.value.start_date, (newStartDate) => {
|
||||
// Only auto-update end date for new projects (not when editing)
|
||||
|
||||
Reference in New Issue
Block a user