Compare commits
3 Commits
841e786fdd
...
442abc3586
| Author | SHA1 | Date | |
|---|---|---|---|
| 442abc3586 | |||
| 9527f06b3f | |||
| 4f9deeb57a |
@@ -5,7 +5,7 @@ from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.user import User, UserRole
|
||||
from models.activity import Activity, ActivityType
|
||||
from models.project import ProjectMember
|
||||
from schemas.activity import ActivityResponse
|
||||
@@ -26,15 +26,17 @@ def get_project_activities(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity feed for a specific project (excludes activities for deleted records)."""
|
||||
# Verify user has access to the project
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins have access to all projects (matches shots.py/assets.py).
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
@@ -66,15 +68,17 @@ def get_task_activities(
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check if user is a member of the project
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins have access to all tasks (matches shots.py/assets.py).
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
@@ -122,34 +126,10 @@ def get_recent_activities(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent activities from all projects the user has access to (excludes activities for deleted records)."""
|
||||
# Get all projects the user is a member of
|
||||
project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
|
||||
if not project_ids and not current_user.is_admin:
|
||||
return []
|
||||
|
||||
# For non-admin users, filter by their project access
|
||||
if not current_user.is_admin:
|
||||
# Get activities from user's projects, excluding deleted records
|
||||
all_activities = []
|
||||
for project_id in project_ids:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=0,
|
||||
limit=limit * 2 # Get more to account for filtering
|
||||
)
|
||||
all_activities.extend(activities)
|
||||
|
||||
# Sort by created_at and apply pagination
|
||||
all_activities.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return all_activities[skip:skip + limit]
|
||||
else:
|
||||
# Admin gets all activities excluding deleted records
|
||||
# Only artists are restricted to their explicit project memberships; coordinators,
|
||||
# directors, developers, and admins see recent activity across all projects
|
||||
# (matches shots.py/assets.py).
|
||||
if current_user.role != UserRole.ARTIST:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
@@ -157,6 +137,30 @@ def get_recent_activities(
|
||||
)
|
||||
return activities
|
||||
|
||||
# Get all projects the artist is a member of
|
||||
project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
all_activities = []
|
||||
for project_id in project_ids:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=0,
|
||||
limit=limit * 2 # Get more to account for filtering
|
||||
)
|
||||
all_activities.extend(activities)
|
||||
|
||||
# Sort by created_at and apply pagination
|
||||
all_activities.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return all_activities[skip:skip + limit]
|
||||
|
||||
|
||||
# Admin-only endpoints that include activities for deleted records
|
||||
@router.get("/admin/project/{project_id}/all", response_model=List[ActivityResponse])
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<ActivityIcon class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No activity yet</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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`.
|
||||
- `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:
|
||||
- [ ] `components/ui/sidebar/SidebarColumnSwitch.vue`
|
||||
- [ ] `components/shot/ShotTableToolbar.vue`
|
||||
- [ ] `components/asset/AssetTableToolbar.vue`
|
||||
- [ ] `components/task/TaskTableToolbar.vue`
|
||||
- [ ] Extract shared `EntityTableToolbar` (search, column-visibility trigger, detail-panel toggle, clear-filters) to de-duplicate the three toolbar files
|
||||
- [ ] Extract `SegmentedToggle` component for the three hand-built view-mode switches (shot grid/list/table, asset grid/list, task all/shots/assets)
|
||||
- [ ] Standardize delete confirmation on the shot pattern (`Dialog` + `Alert` + impact summary + type-to-confirm) for any cascading delete; fix asset's dialog to match or consciously keep it lighter
|
||||
- [ ] Swap hand-rolled checkbox-divs for real `Checkbox`/`DropdownMenuCheckboxItem` in:
|
||||
- [ ] `SidebarColumnSwitch.vue`
|
||||
- [ ] `ShotTableToolbar.vue`
|
||||
- [ ] `AssetTableToolbar.vue`
|
||||
- [ ] `TaskTableToolbar.vue`
|
||||
- [ ] `ShotTaskStatusFilter.vue`
|
||||
- [ ] `asset/TaskStatusFilter.vue`
|
||||
- [ ] Replace ad hoc `size="sm" class="h-8 w-8 p-0"` with `size="icon-sm"` across toolbar/browser icon buttons
|
||||
- [ ] Decide one convention (icon+label vs. icon-only+tooltip) for primary "create" CTAs and apply consistently (shot/asset create buttons currently lack tooltips project/episode create buttons have)
|
||||
- [ ] Standardize row-actions/bulk-actions on `DropdownMenu`/`DropdownMenuItem`; replace ad hoc `<button>` list in `components/task/TaskBulkActionsMenu.vue`
|
||||
- [ ] Extract `DetailPanelShell` (header/close button, loading state, error state, Tabs scaffold, slide-in transition) for `ShotDetailPanel.vue`, `AssetDetailPanel.vue`, `TaskDetailPanel.vue` to share; add missing error state to Task's panel
|
||||
- [x] Extract `ColumnToggleList` component and point at it from:
|
||||
- [x] `components/ui/sidebar/SidebarColumnSwitch.vue`
|
||||
- [x] `components/shot/ShotTableToolbar.vue`
|
||||
- [x] `components/asset/AssetTableToolbar.vue`
|
||||
- [x] `components/task/TaskTableToolbar.vue`
|
||||
|
||||
## 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
|
||||
- [ ] Replace `stores/taskStatuses.ts`'s polling-based in-flight request de-dup (100ms `setInterval` loop, lines 96-117) with promise memoization
|
||||
- [ ] Extend a similar TTL/cache strategy to `stores/assets.ts`, `stores/tasks.ts`, `stores/projects.ts`, `stores/episodes.ts` (currently no caching — every view re-fetches on mount)
|
||||
- [ ] De-duplicate `assets.ts`'s optimistic-update/rollback logic between single (`lines 156-234`) and bulk (`lines 236-356`) task-status updates into one parameterized function
|
||||
- [ ] Add `markRaw()` around icon components stored in reactive state — `stores/projects.ts` `assignProjectIcon`
|
||||
- [ ] Audit `deep: true` watchers, especially `views/ProjectsView.vue:770` (watches the full, non-`markRaw`'d projects array)
|
||||
- [ ] Fix N+1 project-member fetch: hoist `getProjectMembers()` call out of `EditableTaskStatus.vue` (mounted per row × task-type column) into a shared store/cache or parent-passed prop
|
||||
- [ ] Add table virtualization (e.g. `@tanstack/vue-virtual`) to `ShotsDataTable.vue`, `AssetsDataTable.vue`, `TasksDataTable.vue`
|
||||
- [ ] Introduce a shared `EmptyState` component and standardize on `Skeleton` for loading states (currently: 5 files use `Skeleton`, 62 use ad hoc spinners; 31 files hand-roll empty-state text)
|
||||
- [ ] Add toast-on-error for currently-silent secondary loads: episodes/task-types/task-statuses/project-context in `ShotBrowser.vue` (lines 474-508), `stores/notifications.ts:fetchStats`, router auth-init failure (`router/index.ts:190-196`)
|
||||
- [ ] Centralize a `usePermission()`/`can()` composable to consolidate role/admin checks currently duplicated across ~20 components outside the router guard
|
||||
- [ ] Consider splitting the largest files into smaller pieces: `views/project/ShotDetailView.vue` (1658 lines), `views/admin/DeletedItemsManagementView.vue` (1279 lines), and evaluate a shared "entity browser" composable for `ShotBrowser.vue`/`AssetBrowser.vue`'s ~70% structural overlap
|
||||
- [ ] Move ad hoc domain types out of `services/*.ts` into `types/` for discoverability (currently only `auth.ts`, `notification.ts`, `activity.ts` live in `types/`)
|
||||
**Bugs found and fixed during verification (approved mid-implementation):**
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
## Phase 4 — Architecture & tooling investment ✅ (done, scoped)
|
||||
|
||||
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.
|
||||
|
||||
- [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.
|
||||
- [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.
|
||||
- [x] Add `markRaw()` around icon components stored in reactive state — `stores/projects.ts` `assignProjectIcon` and the static `allProjectsView.icon`.
|
||||
- [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.
|
||||
- [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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user