Init Repo
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading task details...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Details -->
|
||||
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Header (Fixed) -->
|
||||
<div class="flex-shrink-0 p-6 border-b">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold truncate">{{ task.name }}</h2>
|
||||
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tabs List (Fixed) -->
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
<TabsTrigger value="notes">
|
||||
Notes
|
||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
||||
{{ notes.length }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="attachments">
|
||||
Attachments
|
||||
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
|
||||
{{ attachments.length }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="submissions">
|
||||
Submissions
|
||||
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
|
||||
{{ submissions.length }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Infos Tab -->
|
||||
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
|
||||
<!-- Task Description -->
|
||||
<div v-if="task.description" class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Description</h3>
|
||||
<p class="text-sm text-muted-foreground">{{ task.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Bar -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Quick Actions</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="canStartTask"
|
||||
@click="handleQuickAction('start')"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
Start Task
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canSubmitWork"
|
||||
@click="handleQuickAction('submit')"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Upload class="h-4 w-4 mr-2" />
|
||||
Submit Work
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canReassign"
|
||||
@click="showAssignmentDialog = true"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<UserPlus class="h-4 w-4 mr-2" />
|
||||
Reassign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Update -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Status</h3>
|
||||
<Select v-model="localStatus" @update:model-value="(value) => handleStatusChange(value as string)">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="not_started">Not Started</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="submitted">Submitted</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="retake">Retake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Task Information -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Task Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Type</Label>
|
||||
<p class="text-sm mt-1">
|
||||
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Deadline</Label>
|
||||
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)">
|
||||
<Calendar class="h-3 w-3" />
|
||||
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Assigned To</Label>
|
||||
<div v-if="task.assigned_user_name" class="mt-1 flex items-center gap-2">
|
||||
<Avatar class="h-6 w-6">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${task.assigned_user_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-xs">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm font-medium">{{ task.assigned_user_name }}</span>
|
||||
</div>
|
||||
<div v-else class="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<User class="h-3 w-3" />
|
||||
Unassigned
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Created</Label>
|
||||
<p>{{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Updated</Label>
|
||||
<p>{{ formatDate(task.updated_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Context</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Project:</span>
|
||||
<span class="font-medium">{{ task.project_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.episode_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Episode:</span>
|
||||
<span class="font-medium">{{ task.episode_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.shot_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Shot:</span>
|
||||
<span class="font-medium">{{ task.shot_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.asset_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Asset:</span>
|
||||
<span class="font-medium">{{ task.asset_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- Attachments Tab -->
|
||||
<TabsContent value="attachments" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskAttachments :task-id="taskId" :attachments="attachments" @attachments-updated="loadAttachments" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- Submissions Tab -->
|
||||
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Dialog -->
|
||||
<Dialog v-model:open="showAssignmentDialog">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a project member to assign this task to.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search members..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No members found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="member in projectMembers"
|
||||
:key="member.user_id"
|
||||
:value="member.user_id.toString()"
|
||||
@click="selectedUserId = member.user_id"
|
||||
class="cursor-pointer"
|
||||
:class="{ 'bg-accent': selectedUserId === member.user_id }"
|
||||
>
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ member.department_role }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedUserId === member.user_id" class="text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showAssignmentDialog = false">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleAssignTask"
|
||||
:disabled="!selectedUserId || assignmentLoading"
|
||||
>
|
||||
{{ assignmentLoading ? 'Assigning...' : 'Assign' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { X, Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusBadge from './TaskStatusBadge.vue'
|
||||
import TaskNotes from './TaskNotes.vue'
|
||||
import TaskAttachments from './TaskAttachments.vue'
|
||||
import TaskSubmissions from './TaskSubmissions.vue'
|
||||
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
taskUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const task = ref<Task | null>(null)
|
||||
const loading = ref(false)
|
||||
const localStatus = ref('')
|
||||
const notes = ref<ProductionNote[]>([])
|
||||
const attachments = ref<TaskAttachment[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const showAssignmentDialog = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
const assignmentLoading = ref(false)
|
||||
|
||||
// Computed properties for quick actions
|
||||
const canStartTask = computed(() => {
|
||||
if (!task.value || !authStore.user) return false
|
||||
return (
|
||||
task.value.assigned_user_id === authStore.user.id &&
|
||||
task.value.status === 'not_started'
|
||||
)
|
||||
})
|
||||
|
||||
const canSubmitWork = computed(() => {
|
||||
if (!task.value || !authStore.user) return false
|
||||
return (
|
||||
task.value.assigned_user_id === authStore.user.id &&
|
||||
(task.value.status === 'in_progress' || task.value.status === 'retake')
|
||||
)
|
||||
})
|
||||
|
||||
const canReassign = computed(() => {
|
||||
if (!authStore.user) return false
|
||||
return authStore.user.is_admin || authStore.user.role === 'coordinator'
|
||||
})
|
||||
|
||||
async function loadTask() {
|
||||
loading.value = true
|
||||
try {
|
||||
task.value = await taskService.getTask(props.taskId)
|
||||
localStatus.value = task.value.status
|
||||
} catch (error: any) {
|
||||
console.error('Error loading task:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to load task',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotes() {
|
||||
try {
|
||||
notes.value = await taskService.getTaskNotes(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading notes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
attachments.value = await taskService.getTaskAttachments(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading attachments:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubmissions() {
|
||||
try {
|
||||
submissions.value = await taskService.getTaskSubmissions(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading submissions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
if (!task.value) return
|
||||
|
||||
try {
|
||||
await taskService.updateTaskStatus(props.taskId, newStatus as any)
|
||||
task.value.status = newStatus as any
|
||||
emit('taskUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Task status updated successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating status:', error)
|
||||
localStatus.value = task.value.status // Revert
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update task status',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAction(action: 'start' | 'submit') {
|
||||
if (!task.value) return
|
||||
|
||||
if (action === 'start') {
|
||||
await handleStatusChange('in_progress')
|
||||
} else if (action === 'submit') {
|
||||
// Trigger the submissions tab to show the upload interface
|
||||
toast({
|
||||
title: 'Submit Work',
|
||||
description: 'Please use the Submissions tab to upload your work'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectMembers() {
|
||||
if (!task.value) return
|
||||
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(task.value.project_id)
|
||||
} catch (error) {
|
||||
console.error('Error loading project members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignTask() {
|
||||
if (!selectedUserId.value || !task.value) return
|
||||
|
||||
assignmentLoading.value = true
|
||||
try {
|
||||
await taskService.assignTask(props.taskId, selectedUserId.value)
|
||||
await loadTask()
|
||||
showAssignmentDialog.value = false
|
||||
emit('taskUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Task assigned successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error assigning task:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to assign task',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
assignmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserDisplayName(member: ProjectMember): string {
|
||||
return `${member.user_first_name} ${member.user_last_name} (${member.department_role})`
|
||||
}
|
||||
|
||||
function getUserInitials(member: ProjectMember): string {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
function getAvatarUrl(url: string | null | undefined) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `http://localhost:8000/${cleanUrl}`
|
||||
}
|
||||
|
||||
function getAssignedUserInitials(name: string) {
|
||||
const parts = name.split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0].charAt(0)}${parts[1].charAt(0)}`.toUpperCase()
|
||||
}
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function formatTaskType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getDeadlineClass(deadline: string | undefined, status: string): string {
|
||||
if (!deadline || status === 'approved') return 'text-muted-foreground'
|
||||
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysUntil < 0) return 'text-destructive'
|
||||
if (daysUntil <= 3) return 'text-orange-600'
|
||||
if (daysUntil <= 7) return 'text-yellow-600'
|
||||
return 'text-foreground'
|
||||
}
|
||||
|
||||
watch(() => props.taskId, () => {
|
||||
loadTask()
|
||||
loadNotes()
|
||||
loadAttachments()
|
||||
loadSubmissions()
|
||||
})
|
||||
|
||||
watch(showAssignmentDialog, (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
loadProjectMembers()
|
||||
selectedUserId.value = task.value?.assigned_user_id || null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTask()
|
||||
loadNotes()
|
||||
loadAttachments()
|
||||
loadSubmissions()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user