31d17780a4
Departments now carry a type (shot or asset) and own a list of task types (e.g. Animation: blocking/primary_pass/second_pass, Composite: first_pass/second_pass, plus a new Simulation department), additive to the existing flat Custom Task Type system. When a task's type belongs to a department, its department is derived and kept in sync server-side across create/update paths; Task Type is now editable in the Task Detail panel and Department options are filtered to the task's shot/asset scope.
718 lines
26 KiB
Vue
718 lines
26 KiB
Vue
<template>
|
|
<div class="flex flex-col h-full">
|
|
<DetailPanelLoading v-if="loading" label="Loading task details..." />
|
|
|
|
<DetailPanelError
|
|
v-else-if="error"
|
|
title="Failed to load task"
|
|
:message="error"
|
|
@retry="loadTask"
|
|
/>
|
|
|
|
<!-- Task Details -->
|
|
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
|
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
|
</DetailPanelHeader>
|
|
|
|
<!-- Tabbed Content -->
|
|
<Tabs :default-value="initialTab || '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" title="Infos">
|
|
<Info class="h-4 w-4" />
|
|
<span class="sr-only">Infos</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="notes" title="Notes">
|
|
<span class="relative inline-flex">
|
|
<MessageSquare class="h-4 w-4" />
|
|
<span
|
|
v-if="notes.length > 0"
|
|
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
|
|
>
|
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
|
</span>
|
|
</span>
|
|
<span class="sr-only">Notes</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="attachments" title="Attachments">
|
|
<Paperclip class="h-4 w-4" />
|
|
<span class="sr-only">Attachments</span>
|
|
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
|
{{ attachments.length }}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="submissions" title="Submissions">
|
|
<Upload class="h-4 w-4" />
|
|
<span class="sr-only">Submissions</span>
|
|
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
|
{{ 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>
|
|
<div class="mt-1">
|
|
<Select :model-value="task.task_type" @update:model-value="(value) => handleTaskTypeChange(value as string)">
|
|
<SelectTrigger class="h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="taskType in taskTypeOptions" :key="taskType" :value="taskType">
|
|
{{ formatTaskType(taskType) }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label class="text-muted-foreground">Department</Label>
|
|
<div class="mt-1">
|
|
<Select :model-value="localDepartment || 'none'" @update:model-value="(value) => handleDepartmentChange(value === 'none' ? '' : (value as string))">
|
|
<SelectTrigger class="h-8">
|
|
<SelectValue placeholder="None" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
<SelectItem v-for="department in departmentOptions" :key="department" :value="department">
|
|
{{ formatDepartment(department) }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label class="text-muted-foreground">Start Date</Label>
|
|
<div class="mt-1">
|
|
<DatePicker
|
|
v-model="localStartDate"
|
|
placeholder="Set start date"
|
|
@update:model-value="(val) => handleDateChange('start_date', val || '')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label class="text-muted-foreground">Deadline</Label>
|
|
<div class="mt-1">
|
|
<DatePicker
|
|
v-model="localDeadline"
|
|
placeholder="Set deadline"
|
|
@update:model-value="(val) => handleDateChange('deadline', val || '')"
|
|
/>
|
|
</div>
|
|
</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="`getInitialsAvatarUrl(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"
|
|
:initial-reply-note-id="initialReplyNoteId"
|
|
@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"
|
|
:task-type="task?.task_type"
|
|
:project-id="task?.project_id"
|
|
:name="task?.shot_name || task?.asset_name"
|
|
:task-name="task?.name"
|
|
:project-name="task?.project_name"
|
|
@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://ui-avatars.com/api/?name=${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 { Play, Upload, UserPlus, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Label } from '@/components/ui/label'
|
|
import { DatePicker } from '@/components/ui/date-picker'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
|
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 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 { customTaskTypeService } from '@/services/customTaskType'
|
|
import { useDepartmentsStore } from '@/stores/departments'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from '@/components/ui/toast/use-toast'
|
|
|
|
const props = defineProps<{
|
|
taskId: number
|
|
initialTab?: string
|
|
initialReplyNoteId?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
taskUpdated: []
|
|
}>()
|
|
|
|
const { toast } = useToast()
|
|
const authStore = useAuthStore()
|
|
const { isCoordinatorOrAdmin } = usePermission()
|
|
const departmentsStore = useDepartmentsStore()
|
|
|
|
const task = ref<Task | null>(null)
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const localStatus = ref('')
|
|
const localStartDate = ref('')
|
|
const localDeadline = ref('')
|
|
const localDepartment = ref('')
|
|
const flatShotTaskTypes = ref<string[]>([])
|
|
const flatAssetTaskTypes = ref<string[]>([])
|
|
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(() => isCoordinatorOrAdmin.value)
|
|
|
|
// Departments are type-scoped (shot vs asset); a standalone task (neither
|
|
// shot nor asset) falls back to the unfiltered list.
|
|
const departmentOptions = computed(() => {
|
|
if (!task.value) return []
|
|
if (task.value.shot_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'shot').map(d => d.name)
|
|
if (task.value.asset_id) return departmentsStore.getDepartmentsByType(task.value.project_id, 'asset').map(d => d.name)
|
|
return departmentsStore.getAllDepartmentOptions(task.value.project_id)
|
|
})
|
|
|
|
// Task Type options come from the current department's owned task types when
|
|
// it has any; otherwise fall back to the existing flat asset/shot task type list.
|
|
const taskTypeOptions = computed(() => {
|
|
if (!task.value) return []
|
|
const departmentTaskTypes = localDepartment.value
|
|
? departmentsStore.getDepartmentTaskTypes(task.value.project_id, localDepartment.value)
|
|
: []
|
|
if (departmentTaskTypes.length > 0) return departmentTaskTypes
|
|
|
|
const flatTypes = task.value.shot_id ? flatShotTaskTypes.value : flatAssetTaskTypes.value
|
|
// Always include the task's current type, even if it isn't in either list
|
|
// (e.g. a legacy or since-removed value), so the Select never shows blank.
|
|
return flatTypes.includes(task.value.task_type) ? flatTypes : [task.value.task_type, ...flatTypes]
|
|
})
|
|
|
|
function formatDepartment(department: string): string {
|
|
return department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
|
}
|
|
|
|
async function loadTask() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
task.value = await taskService.getTask(props.taskId)
|
|
localStatus.value = task.value.status
|
|
localStartDate.value = task.value.start_date || ''
|
|
localDeadline.value = task.value.deadline || ''
|
|
localDepartment.value = task.value.department || ''
|
|
departmentsStore.fetchProjectDepartments(task.value.project_id)
|
|
customTaskTypeService.getAllTaskTypes(task.value.project_id).then(types => {
|
|
flatShotTaskTypes.value = types.shot_task_types
|
|
flatAssetTaskTypes.value = types.asset_task_types
|
|
}).catch(err => console.error('Failed to load task types:', err))
|
|
} catch (err: any) {
|
|
console.error('Error loading task:', err)
|
|
error.value = err.response?.data?.detail || 'Failed to load task'
|
|
toast({
|
|
title: 'Error',
|
|
description: error.value,
|
|
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 handleDateChange(field: 'start_date' | 'deadline', value: string) {
|
|
if (!task.value) return
|
|
|
|
const previous = task.value[field]
|
|
try {
|
|
const updated = await taskService.updateTask(props.taskId, { [field]: value || null } as any)
|
|
task.value[field] = updated[field]
|
|
emit('taskUpdated')
|
|
toast({
|
|
title: 'Success',
|
|
description: `Task ${field === 'start_date' ? 'start date' : 'deadline'} updated successfully`
|
|
})
|
|
} catch (error: any) {
|
|
console.error(`Error updating ${field}:`, error)
|
|
if (field === 'start_date') localStartDate.value = previous || ''
|
|
else localDeadline.value = previous || ''
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || `Failed to update ${field === 'start_date' ? 'start date' : 'deadline'}`,
|
|
variant: 'destructive'
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleDepartmentChange(value: string) {
|
|
if (!task.value) return
|
|
|
|
const previousDepartment = task.value.department
|
|
const previousTaskType = task.value.task_type
|
|
|
|
// If the current task type doesn't belong to the newly-picked department
|
|
// (and that department has its own task types), reset to its first one so
|
|
// department and task type stay consistent.
|
|
const newDepartmentTaskTypes = value ? departmentsStore.getDepartmentTaskTypes(task.value.project_id, value) : []
|
|
const needsTaskTypeReset = newDepartmentTaskTypes.length > 0 && !newDepartmentTaskTypes.includes(task.value.task_type)
|
|
|
|
const payload: Record<string, any> = { department: value || null }
|
|
if (needsTaskTypeReset) {
|
|
payload.task_type = newDepartmentTaskTypes[0]
|
|
}
|
|
|
|
try {
|
|
const updated = await taskService.updateTask(props.taskId, payload as any)
|
|
task.value.department = updated.department
|
|
task.value.task_type = updated.task_type
|
|
localDepartment.value = updated.department || ''
|
|
emit('taskUpdated')
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Task department updated successfully'
|
|
})
|
|
} catch (error: any) {
|
|
console.error('Error updating department:', error)
|
|
localDepartment.value = previousDepartment || ''
|
|
task.value.task_type = previousTaskType
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to update task department',
|
|
variant: 'destructive'
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleTaskTypeChange(value: string) {
|
|
if (!task.value || value === task.value.task_type) return
|
|
|
|
const previousTaskType = task.value.task_type
|
|
const previousDepartment = task.value.department
|
|
|
|
try {
|
|
const updated = await taskService.updateTask(props.taskId, { task_type: value } as any)
|
|
task.value.task_type = updated.task_type
|
|
task.value.department = updated.department
|
|
localDepartment.value = updated.department || ''
|
|
emit('taskUpdated')
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Task type updated successfully'
|
|
})
|
|
} catch (error: any) {
|
|
console.error('Error updating task type:', error)
|
|
task.value.task_type = previousTaskType
|
|
task.value.department = previousDepartment
|
|
localDepartment.value = previousDepartment || ''
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to update task type',
|
|
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()
|
|
}
|
|
|
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
|
import { usePermission } from '@/composables/usePermission'
|
|
|
|
const { getAvatarUrl } = useAvatarUrl()
|
|
|
|
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' })
|
|
}
|
|
|
|
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>
|