Modify Shot Data Table Functions
This commit is contained in:
parent
c8c4c99a6e
commit
1f229bff6c
|
|
@ -367,7 +367,8 @@ async def list_project_members(
|
||||||
joined_at=member.joined_at,
|
joined_at=member.joined_at,
|
||||||
user_email=member.user.email,
|
user_email=member.user.email,
|
||||||
user_first_name=member.user.first_name,
|
user_first_name=member.user.first_name,
|
||||||
user_last_name=member.user.last_name
|
user_last_name=member.user.last_name,
|
||||||
|
user_avatar_url=member.user.avatar_url
|
||||||
)
|
)
|
||||||
result.append(member_data)
|
result.append(member_data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,8 @@ async def create_task(
|
||||||
"shot_name": db_task.shot.name if db_task.shot else None,
|
"shot_name": db_task.shot.name if db_task.shot else None,
|
||||||
"asset_name": db_task.asset.name if db_task.asset else None,
|
"asset_name": db_task.asset.name if db_task.asset else None,
|
||||||
"assigned_user_name": f"{db_task.assigned_user.first_name} {db_task.assigned_user.last_name}" if db_task.assigned_user else None,
|
"assigned_user_name": f"{db_task.assigned_user.first_name} {db_task.assigned_user.last_name}" if db_task.assigned_user else None,
|
||||||
"assigned_user_email": db_task.assigned_user.email if db_task.assigned_user else None
|
"assigned_user_email": db_task.assigned_user.email if db_task.assigned_user else None,
|
||||||
|
"assigned_user_avatar_url": db_task.assigned_user.avatar_url if db_task.assigned_user else None
|
||||||
}
|
}
|
||||||
|
|
||||||
return TaskResponse(**task_data)
|
return TaskResponse(**task_data)
|
||||||
|
|
@ -744,7 +745,8 @@ async def get_task(
|
||||||
"shot_name": task.shot.name if task.shot else None,
|
"shot_name": task.shot.name if task.shot else None,
|
||||||
"asset_name": task.asset.name if task.asset else None,
|
"asset_name": task.asset.name if task.asset else None,
|
||||||
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
||||||
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None,
|
||||||
|
"assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None
|
||||||
}
|
}
|
||||||
|
|
||||||
return TaskResponse(**task_data)
|
return TaskResponse(**task_data)
|
||||||
|
|
@ -862,7 +864,8 @@ async def update_task(
|
||||||
"shot_name": task.shot.name if task.shot else None,
|
"shot_name": task.shot.name if task.shot else None,
|
||||||
"asset_name": task.asset.name if task.asset else None,
|
"asset_name": task.asset.name if task.asset else None,
|
||||||
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
||||||
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None,
|
||||||
|
"assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None
|
||||||
}
|
}
|
||||||
|
|
||||||
return TaskResponse(**task_data)
|
return TaskResponse(**task_data)
|
||||||
|
|
@ -951,7 +954,8 @@ async def update_task_status(
|
||||||
"shot_name": task.shot.name if task.shot else None,
|
"shot_name": task.shot.name if task.shot else None,
|
||||||
"asset_name": task.asset.name if task.asset else None,
|
"asset_name": task.asset.name if task.asset else None,
|
||||||
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
||||||
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None,
|
||||||
|
"assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None
|
||||||
}
|
}
|
||||||
|
|
||||||
return TaskResponse(**task_data)
|
return TaskResponse(**task_data)
|
||||||
|
|
@ -1049,7 +1053,8 @@ async def assign_task(
|
||||||
"shot_name": task.shot.name if task.shot else None,
|
"shot_name": task.shot.name if task.shot else None,
|
||||||
"asset_name": task.asset.name if task.asset else None,
|
"asset_name": task.asset.name if task.asset else None,
|
||||||
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
"assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None,
|
||||||
"assigned_user_email": task.assigned_user.email if task.assigned_user else None
|
"assigned_user_email": task.assigned_user.email if task.assigned_user else None,
|
||||||
|
"assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None
|
||||||
}
|
}
|
||||||
|
|
||||||
return TaskResponse(**task_data)
|
return TaskResponse(**task_data)
|
||||||
|
|
@ -1130,6 +1135,7 @@ async def get_task_notes(
|
||||||
"user_first_name": note.user.first_name,
|
"user_first_name": note.user.first_name,
|
||||||
"user_last_name": note.user.last_name,
|
"user_last_name": note.user.last_name,
|
||||||
"user_email": note.user.email,
|
"user_email": note.user.email,
|
||||||
|
"user_avatar_url": note.user.avatar_url,
|
||||||
"child_notes": []
|
"child_notes": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1208,6 +1214,7 @@ async def create_task_note(
|
||||||
"user_first_name": db_note.user.first_name,
|
"user_first_name": db_note.user.first_name,
|
||||||
"user_last_name": db_note.user.last_name,
|
"user_last_name": db_note.user.last_name,
|
||||||
"user_email": db_note.user.email,
|
"user_email": db_note.user.email,
|
||||||
|
"user_avatar_url": db_note.user.avatar_url,
|
||||||
"child_notes": []
|
"child_notes": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1256,6 +1263,7 @@ async def update_task_note(
|
||||||
"user_first_name": note.user.first_name,
|
"user_first_name": note.user.first_name,
|
||||||
"user_last_name": note.user.last_name,
|
"user_last_name": note.user.last_name,
|
||||||
"user_email": note.user.email,
|
"user_email": note.user.email,
|
||||||
|
"user_avatar_url": note.user.avatar_url,
|
||||||
"child_notes": []
|
"child_notes": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ class ProjectMemberResponse(ProjectMemberBase):
|
||||||
user_email: str
|
user_email: str
|
||||||
user_first_name: str
|
user_first_name: str
|
||||||
user_last_name: str
|
user_last_name: str
|
||||||
|
user_avatar_url: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class TaskResponse(TaskBase):
|
||||||
asset_name: Optional[str] = None
|
asset_name: Optional[str] = None
|
||||||
assigned_user_name: Optional[str] = None
|
assigned_user_name: Optional[str] = None
|
||||||
assigned_user_email: Optional[str] = None
|
assigned_user_email: Optional[str] = None
|
||||||
|
assigned_user_avatar_url: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -78,6 +79,7 @@ class TaskListResponse(BaseModel):
|
||||||
asset_name: Optional[str] = None
|
asset_name: Optional[str] = None
|
||||||
assigned_user_id: Optional[int] = None
|
assigned_user_id: Optional[int] = None
|
||||||
assigned_user_name: Optional[str] = None
|
assigned_user_name: Optional[str] = None
|
||||||
|
assigned_user_avatar_url: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -110,6 +112,7 @@ class ProductionNoteResponse(ProductionNoteBase):
|
||||||
user_first_name: str
|
user_first_name: str
|
||||||
user_last_name: str
|
user_last_name: str
|
||||||
user_email: str
|
user_email: str
|
||||||
|
user_avatar_url: Optional[str] = None
|
||||||
|
|
||||||
# Child notes for threading
|
# Child notes for threading
|
||||||
child_notes: Optional[List['ProductionNoteResponse']] = None
|
child_notes: Optional[List['ProductionNoteResponse']] = None
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,11 @@
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="activity.user.avatar_url"
|
v-if="activity.user.avatar_url"
|
||||||
:src="getAvatarUrl(activity.user.avatar_url, activity.user.id)"
|
:src="getAvatarUrl(activity.user.avatar_url, activity.user.first_name, activity.user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${activity.user.first_name} ${activity.user.last_name}`"
|
:src="getInitialsAvatarUrl(activity.user.first_name, activity.user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{{ getInitials(activity.user) }}
|
{{ getInitials(activity.user) }}
|
||||||
|
|
@ -275,12 +275,7 @@ function navigateToProject(projectId: number) {
|
||||||
router.push(`/projects/${projectId}`)
|
router.push(`/projects/${projectId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarUrl(url: string | null | undefined, userId?: number): string {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!url) return ''
|
|
||||||
// If it's already a full URL, return it
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
if (url.startsWith('http')) return url
|
|
||||||
// Use direct static file serving
|
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
>
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||||
<div class="text-xs text-muted-foreground">{{ user.email }} • {{ user.role }}</div>
|
<div class="text-xs text-muted-foreground">{{ user.email }} ??{{ user.role }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground text-right">
|
<div class="text-xs text-muted-foreground text-right">
|
||||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,11 @@
|
||||||
<Avatar class="h-10 w-10">
|
<Avatar class="h-10 w-10">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user?.avatar_url"
|
v-if="user?.avatar_url"
|
||||||
:src="getAvatarUrl(user.avatar_url)"
|
:src="getAvatarUrl(user.avatar_url, user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user?.first_name} ${user?.last_name}`"
|
:src="getInitialsAvatarUrl(user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -116,14 +116,9 @@ const userInitials = computed(() => {
|
||||||
return `${user.value.first_name.charAt(0)}${user.value.last_name.charAt(0)}`.toUpperCase()
|
return `${user.value.first_name.charAt(0)}${user.value.last_name.charAt(0)}`.toUpperCase()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getAvatarUrl = (url: string | null | undefined) => {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!url) return ''
|
|
||||||
// If it's already a full URL, return it
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
if (url.startsWith('http')) return url
|
|
||||||
// Use direct static file serving
|
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate breadcrumbs based on current route with enhanced context
|
// Generate breadcrumbs based on current route with enhanced context
|
||||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="member.user_id"
|
v-if="member.user_id"
|
||||||
:src="getAvatarUrl(member.user_id)"
|
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -378,22 +378,9 @@ const getUserInitials = (member: ProjectMember) => {
|
||||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvatarUrl = (userIdOrUrl: string | number | null | undefined) => {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!userIdOrUrl) return ''
|
|
||||||
// If it's a number, we don't have the avatar URL, so we can't display avatars
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
if (typeof userIdOrUrl === 'number') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
// If it's already a full URL, return it
|
|
||||||
if (userIdOrUrl.startsWith('http')) return userIdOrUrl
|
|
||||||
// Check if it looks like a user ID (numeric string)
|
|
||||||
if (/^\d+$/.test(userIdOrUrl)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
// Use direct static file serving for avatar URLs
|
|
||||||
const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,50 @@
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-6 w-6 p-0 hover:bg-accent relative z-10"
|
class="h-6 w-6 p-0 hover:bg-accent relative"
|
||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
@click="ensureMembersLoaded"
|
@click.stop="ensureMembersLoaded"
|
||||||
>
|
>
|
||||||
<Avatar class="h-4 w-4" v-if="assignedUser">
|
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||||
<AvatarImage :src="getAvatarUrl(assignedUser.user_id)" />
|
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<User class="h-3 w-3" v-else />
|
<User class="h-3 w-3" v-else />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-64 p-2 z-50" align="start" side="bottom" :side-offset="4">
|
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||||
|
|
||||||
<!-- Debug info -->
|
<!-- Current Assignment Display with X button -->
|
||||||
<div class="px-2 py-1 text-xs text-muted-foreground">
|
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
|
||||||
Project ID: {{ projectId }}, Members: {{ projectMembers.length }}
|
<Avatar class="h-8 w-8">
|
||||||
|
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||||
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground">Current</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
@click.stop="handleAssignUser(null)"
|
||||||
|
:disabled="isAssigning"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search members..."
|
||||||
|
class="pl-7 h-8 text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
|
|
@ -80,37 +106,30 @@
|
||||||
|
|
||||||
<!-- Content when members are loaded -->
|
<!-- Content when members are loaded -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Unassign option -->
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="w-full justify-start"
|
|
||||||
@click="handleAssignUser(null)"
|
|
||||||
:disabled="isAssigning"
|
|
||||||
>
|
|
||||||
<UserX class="h-4 w-4 mr-2" />
|
|
||||||
Unassign
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Project members list -->
|
<!-- Project members list -->
|
||||||
<div class="max-h-48 overflow-y-auto">
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
|
||||||
|
No matching members found
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-for="member in projectMembers"
|
v-else
|
||||||
|
v-for="member in filteredProjectMembers"
|
||||||
:key="member.user_id"
|
:key="member.user_id"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full justify-start"
|
class="w-full justify-start h-10"
|
||||||
@click="handleAssignUser(member.user_id)"
|
@click="handleAssignUser(member.user_id)"
|
||||||
:disabled="isAssigning"
|
:disabled="isAssigning"
|
||||||
>
|
>
|
||||||
<Avatar class="h-4 w-4 mr-2">
|
<Avatar class="h-8 w-8 mr-2">
|
||||||
<AvatarImage :src="getAvatarUrl(member.user_id)" />
|
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="flex flex-col items-start">
|
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||||
<span class="text-sm">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||||
<span class="text-xs text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -144,8 +163,9 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { User, UserX } from 'lucide-vue-next'
|
import { User, UserX, Search, Check, X } from 'lucide-vue-next'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/shot'
|
import { TaskStatus } from '@/services/shot'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService } from '@/services/task'
|
||||||
|
|
@ -183,6 +203,20 @@ const isUpdating = ref(false)
|
||||||
const isAssigning = ref(false)
|
const isAssigning = ref(false)
|
||||||
const isLoadingMembers = ref(false)
|
const isLoadingMembers = ref(false)
|
||||||
const projectMembers = ref<ProjectMember[]>([])
|
const projectMembers = ref<ProjectMember[]>([])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Filtered project members based on search query
|
||||||
|
const filteredProjectMembers = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
return projectMembers.value
|
||||||
|
}
|
||||||
|
const query = searchQuery.value.toLowerCase().trim()
|
||||||
|
return projectMembers.value.filter(member => {
|
||||||
|
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
|
||||||
|
const departmentRole = member.department_role?.toLowerCase() || ''
|
||||||
|
return fullName.includes(query) || departmentRole.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Get loading state from store
|
// Get loading state from store
|
||||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||||
|
|
@ -257,10 +291,9 @@ const getUserInitials = (member: ProjectMember): string => {
|
||||||
return (first + last).toUpperCase()
|
return (first + last).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get avatar URL
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
const getAvatarUrl = (userId: number): string => {
|
|
||||||
return `https://api.dicebear.com/7.x/initials/svg?seed=${userId}`
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch custom statuses for the project using store
|
// Fetch custom statuses for the project using store
|
||||||
const fetchStatuses = async () => {
|
const fetchStatuses = async () => {
|
||||||
|
|
@ -353,7 +386,10 @@ const handleAssignUser = async (userId: number | null) => {
|
||||||
emit('assignment-updated', props.shotId, props.taskType, userId)
|
emit('assignment-updated', props.shotId, props.taskType, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Popover will close naturally when clicking outside or on assignment
|
// Close popover by simulating click outside after assignment
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
}, 100)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to assign task:', error)
|
console.error('Failed to assign task:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,6 @@
|
||||||
<span>Episode</span>
|
<span>Episode</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="frameRange" @click="toggleColumn('frameRange')">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="isColumnVisible('frameRange')"
|
|
||||||
@change="handleCheckboxChange('frameRange', $event)"
|
|
||||||
class="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span>Frame Range</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="status" @click="toggleColumn('status')">
|
<SelectItem value="status" @click="toggleColumn('status')">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -88,20 +77,6 @@
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Other Columns</SelectLabel>
|
|
||||||
<SelectItem value="description" @click="toggleColumn('description')">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="isColumnVisible('description')"
|
|
||||||
@change="handleCheckboxChange('description', $event)"
|
|
||||||
class="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span>Description</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -305,10 +305,8 @@ const allColumns = computed(() => [
|
||||||
{ id: 'thumbnail', label: 'Thumbnail', type: 'default' },
|
{ id: 'thumbnail', label: 'Thumbnail', type: 'default' },
|
||||||
{ id: 'name', label: 'Shot Name', type: 'default' },
|
{ id: 'name', label: 'Shot Name', type: 'default' },
|
||||||
{ id: 'episode', label: 'Episode', type: 'default' },
|
{ id: 'episode', label: 'Episode', type: 'default' },
|
||||||
{ id: 'frameRange', label: 'Frame Range', type: 'default' },
|
|
||||||
{ id: 'frames', label: 'Frames', type: 'default' },
|
{ id: 'frames', label: 'Frames', type: 'default' },
|
||||||
{ id: 'status', label: 'Status', type: 'default' },
|
{ id: 'status', label: 'Status', type: 'default' },
|
||||||
{ id: 'description', label: 'Description', type: 'default' },
|
|
||||||
...props.allTaskTypes.map(taskType => ({
|
...props.allTaskTypes.map(taskType => ({
|
||||||
id: taskType,
|
id: taskType,
|
||||||
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
|
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export const createShotColumns = (
|
||||||
id: 'thumbnail',
|
id: 'thumbnail',
|
||||||
header: 'Thumbnail',
|
header: 'Thumbnail',
|
||||||
cell: () => {
|
cell: () => {
|
||||||
return h('div', { class: 'w-20 h-11 bg-muted flex items-center justify-center' }, [
|
return h('div', { class: 'w-20 h-11 bg-gray-500/20 flex items-center justify-center' }, [
|
||||||
h(Camera, { class: 'h-6 w-6 text-muted-foreground' }),
|
h(Camera, { class: 'h-6 w-6 text-muted-foreground text-gray-900' }),
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
|
@ -121,16 +121,6 @@ export const createShotColumns = (
|
||||||
return h(Badge, { variant: 'outline', class: 'text-xs' }, () => episodeName)
|
return h(Badge, { variant: 'outline', class: 'text-xs' }, () => episodeName)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Frame Range column (not sortable)
|
|
||||||
{
|
|
||||||
id: 'frameRange',
|
|
||||||
header: 'Frame Range',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const shot = row.original
|
|
||||||
return h('span', { class: 'text-sm' }, `${shot.frame_start}-${shot.frame_end}`)
|
|
||||||
},
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
// Frames column (frame count)
|
// Frames column (frame count)
|
||||||
{
|
{
|
||||||
id: 'frames',
|
id: 'frames',
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Avatar class="h-4 w-4">
|
<Avatar class="h-4 w-4">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${attachment.user_first_name} ${attachment.user_last_name}`"
|
:src="getInitialsAvatarUrl(attachment.user_first_name, attachment.user_last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(attachment.user_first_name, attachment.user_last_name) }}</AvatarFallback>
|
<AvatarFallback class="text-[8px]">{{ getUserInitials(attachment.user_first_name, attachment.user_last_name) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -84,6 +84,9 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import type { TaskAttachment } from '@/services/task'
|
import type { TaskAttachment } from '@/services/task'
|
||||||
import { apiClient } from '@/services/api'
|
import { apiClient } from '@/services/api'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
|
const { getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
attachment: TaskAttachment
|
attachment: TaskAttachment
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="note.user_id"
|
v-if="note.user_id"
|
||||||
:src="getAvatarUrl(note.user_id)"
|
:src="getAvatarUrl(note.user_avatar_url, note.user_first_name, note.user_last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${note.user_first_name} ${note.user_last_name}`"
|
:src="`https://ui-avatars.com/api/?name=${note.user_first_name} ${note.user_last_name}`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{{ getInitials(note.user_first_name, note.user_last_name) }}
|
{{ getInitials(note.user_first_name, note.user_last_name) }}
|
||||||
|
|
@ -194,9 +194,7 @@ async function handleDelete() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarUrl(userId: number | null | undefined) {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
// For NoteItem, we don't have the avatar URL, so we can't display avatars
|
|
||||||
// This would require updating the backend API to include avatar URLs in note responses
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
return ''
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar class="h-5 w-5">
|
<Avatar class="h-5 w-5">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${submission.user_first_name} ${submission.user_last_name}`"
|
:src="`https://ui-avatars.com/api/?name=${submission.user_first_name} ${submission.user_last_name}`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
|
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<div v-if="task.assigned_user_name" class="mt-1 flex items-center gap-2">
|
<div v-if="task.assigned_user_name" class="mt-1 flex items-center gap-2">
|
||||||
<Avatar class="h-6 w-6">
|
<Avatar class="h-6 w-6">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${task.assigned_user_name}`"
|
:src="`getInitialsAvatarUrl(task.assigned_user_name)`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-xs">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
<AvatarFallback class="text-xs">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -228,7 +228,7 @@
|
||||||
<div class="flex items-center gap-3 w-full">
|
<div class="flex items-center gap-3 w-full">
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -478,12 +478,9 @@ function getUserInitials(member: ProjectMember): string {
|
||||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarUrl(url: string | null | undefined) {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!url) return ''
|
|
||||||
if (url.startsWith('http')) return url
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `http://localhost:8000/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAssignedUserInitials(name: string) {
|
function getAssignedUserInitials(name: string) {
|
||||||
const parts = name.split(' ')
|
const parts = name.split(' ')
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,11 @@
|
||||||
<Avatar class="h-5 w-5">
|
<Avatar class="h-5 w-5">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="task.assigned_user_id"
|
v-if="task.assigned_user_id"
|
||||||
:src="getAvatarUrl(task.assigned_user_id)"
|
:src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${task.assigned_user_name}`"
|
:src="`getInitialsAvatarUrl(task.assigned_user_name)`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-[10px]">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
<AvatarFallback class="text-[10px]">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -268,11 +268,11 @@
|
||||||
<Avatar class="h-6 w-6">
|
<Avatar class="h-6 w-6">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="member.user_id"
|
v-if="member.user_id"
|
||||||
:src="getAvatarUrl(member.user_id)"
|
:src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-xs">{{ member.user_first_name.charAt(0) }}{{ member.user_last_name.charAt(0) }}</AvatarFallback>
|
<AvatarFallback class="text-xs">{{ member.user_first_name.charAt(0) }}{{ member.user_last_name.charAt(0) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -581,22 +581,9 @@ function clearFilters() {
|
||||||
sortBy.value = 'deadline'
|
sortBy.value = 'deadline'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarUrl(userIdOrUrl: string | number | null | undefined) {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!userIdOrUrl) return ''
|
|
||||||
// If it's a number, we don't have the avatar URL, so we can't display avatars
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
if (typeof userIdOrUrl === 'number') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
// If it's already a full URL, return it
|
|
||||||
if (userIdOrUrl.startsWith('http')) return userIdOrUrl
|
|
||||||
// Check if it looks like a user ID (numeric string)
|
|
||||||
if (/^\d+$/.test(userIdOrUrl)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
// Use direct static file serving for avatar URLs
|
|
||||||
const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAssignedUserInitials(name: string) {
|
function getAssignedUserInitials(name: string) {
|
||||||
const parts = name.split(' ')
|
const parts = name.split(' ')
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const props = defineProps<{
|
||||||
<td
|
<td
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
|
'pl-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="currentAvatarUrl"
|
v-if="currentAvatarUrl"
|
||||||
:src="getAvatarUrl(currentAvatarUrl)"
|
:src="getAvatarUrl(currentAvatarUrl, firstName, lastName)"
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -90,6 +90,7 @@ import { Upload, X, Loader2, Camera } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { userService } from '@/services/user'
|
import { userService } from '@/services/user'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -118,14 +119,7 @@ const initials = computed(() => {
|
||||||
return (first + last).toUpperCase() || '?'
|
return (first + last).toUpperCase() || '?'
|
||||||
})
|
})
|
||||||
|
|
||||||
const getAvatarUrl = (url: string | null | undefined) => {
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
if (!url) return ''
|
|
||||||
// If it's already a full URL, return it
|
|
||||||
if (url.startsWith('http')) return url
|
|
||||||
// Use direct static file serving
|
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
const triggerFileInput = () => {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<Avatar class="h-10 w-10">
|
<Avatar class="h-10 w-10">
|
||||||
<AvatarImage :src="`https://api.dicebear.com/7.x/initials/svg?seed=${user.first_name} ${user.last_name}`" />
|
<AvatarImage :src="getInitialsAvatarUrl(user.first_name, user.last_name)" />
|
||||||
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -53,6 +53,9 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Check, X } from 'lucide-vue-next'
|
import { Check, X } from 'lucide-vue-next'
|
||||||
import type { User } from '@/types/auth'
|
import type { User } from '@/types/auth'
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
|
const { getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<div v-if="user" class="flex items-center space-x-3 p-3 bg-muted rounded-md">
|
<div v-if="user" class="flex items-center space-x-3 p-3 bg-muted rounded-md">
|
||||||
<Avatar class="h-10 w-10">
|
<Avatar class="h-10 w-10">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user.first_name} ${user.last_name}`"
|
:src="getInitialsAvatarUrl(user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>{{ getUserInitials(user) }}</AvatarFallback>
|
<AvatarFallback>{{ getUserInitials(user) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -90,6 +90,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
|
|
||||||
|
const { getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,11 @@
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user.avatar_url"
|
v-if="user.avatar_url"
|
||||||
:src="getAvatarUrl(user.avatar_url, user.id)"
|
:src="getAvatarUrl(user.avatar_url, user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user.first_name} ${user.last_name}`"
|
:src="getInitialsAvatarUrl(user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>{{ getUserInitials(user) }}</AvatarFallback>
|
<AvatarFallback>{{ getUserInitials(user) }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -343,14 +343,9 @@ const getUserInitials = (user: User) => {
|
||||||
return `${user.first_name.charAt(0)}${user.last_name.charAt(0)}`.toUpperCase();
|
return `${user.first_name.charAt(0)}${user.last_name.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvatarUrl = (url: string | null | undefined, userId?: number) => {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!url) return ''
|
|
||||||
// If it's already a full URL, return it
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
if (url.startsWith('http')) return url
|
|
||||||
// Use direct static file serving
|
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString("en-US", {
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Unified avatar URL composable
|
||||||
|
* Provides consistent avatar handling across all components
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get avatar URL with fallback to initials
|
||||||
|
* @param avatarUrl - The avatar URL from backend (can be full URL, relative path, or null/undefined)
|
||||||
|
* @param firstName - User's first name for fallback
|
||||||
|
* @param lastName - User's last name for fallback
|
||||||
|
* @returns Full avatar URL or initials fallback URL
|
||||||
|
*/
|
||||||
|
export function useAvatarUrl() {
|
||||||
|
const getAvatarUrl = (
|
||||||
|
avatarUrl: string | null | undefined,
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string
|
||||||
|
): string => {
|
||||||
|
// Handle null/undefined avatar URL
|
||||||
|
if (!avatarUrl) {
|
||||||
|
// Return initials fallback with name
|
||||||
|
const name = [firstName, lastName].filter(Boolean).join(' ') || 'default'
|
||||||
|
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&color=fff`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's already a full URL (http/https), return as-is
|
||||||
|
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
|
||||||
|
return avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle relative paths (e.g., "backend/avatars/..." or "avatars/...")
|
||||||
|
const cleanUrl = avatarUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||||
|
return `/${cleanUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initials avatar URL for fallback display
|
||||||
|
* @param firstName - User's first name
|
||||||
|
* @param lastName - User's last name
|
||||||
|
* @returns Initials avatar URL
|
||||||
|
*/
|
||||||
|
const getInitialsAvatarUrl = (
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string
|
||||||
|
): string => {
|
||||||
|
// Use name combination
|
||||||
|
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||||
|
if (name) {
|
||||||
|
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&color=fff`
|
||||||
|
}
|
||||||
|
// Ultimate fallback
|
||||||
|
return 'https://ui-avatars.com/api/?name=User&background=random&color=fff'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAvatarUrl,
|
||||||
|
getInitialsAvatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ export interface ProjectMember {
|
||||||
user_email: string
|
user_email: string
|
||||||
user_first_name: string
|
user_first_name: string
|
||||||
user_last_name: string
|
user_last_name: string
|
||||||
|
user_avatar_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectCreate {
|
export interface ProjectCreate {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface Task {
|
||||||
assigned_user_id?: number
|
assigned_user_id?: number
|
||||||
assigned_user_name?: string
|
assigned_user_name?: string
|
||||||
assigned_user_email?: string
|
assigned_user_email?: string
|
||||||
|
assigned_user_avatar_url?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +66,7 @@ export interface ProductionNote {
|
||||||
user_first_name: string
|
user_first_name: string
|
||||||
user_last_name: string
|
user_last_name: string
|
||||||
user_email: string
|
user_email: string
|
||||||
|
user_avatar_url?: string | null
|
||||||
child_notes?: ProductionNote[]
|
child_notes?: ProductionNote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,11 @@
|
||||||
<Avatar class="h-24 w-24">
|
<Avatar class="h-24 w-24">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user?.avatar_url"
|
v-if="user?.avatar_url"
|
||||||
:src="getAvatarUrl(user.avatar_url)"
|
:src="getAvatarUrl(user.avatar_url, user.first_name, user.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-else
|
v-else
|
||||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user?.first_name} ${user?.last_name}`"
|
:src="getInitialsAvatarUrl(user?.first_name, user?.last_name)"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback class="text-2xl">{{ userInitials }}</AvatarFallback>
|
<AvatarFallback class="text-2xl">{{ userInitials }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -650,14 +650,9 @@ const showSuccessMessage = (message: string) => {
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvatarUrl = (url: string | null | undefined) => {
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
if (!url) return ''
|
|
||||||
// If it's already a full URL, return it
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
||||||
if (url.startsWith('http')) return url
|
|
||||||
// Use direct static file serving
|
|
||||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
|
||||||
return `/${cleanUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerAvatarUpload = () => {
|
const triggerAvatarUpload = () => {
|
||||||
avatarFileInput.value?.click()
|
avatarFileInput.value?.click()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue