|
|
|
@@ -67,23 +67,78 @@
|
|
|
|
|
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
|
|
|
|
|
{{ error }}
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="flex-1 overflow-auto">
|
|
|
|
|
<div v-if="unscheduledCount > 0" class="px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
|
|
|
|
|
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
|
|
|
|
<div v-if="unscheduledCount > 0" class="flex-shrink-0 px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
|
|
|
|
|
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
|
|
|
|
|
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="taskTypeGroups.length === 0" class="p-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
<div v-if="taskTypeGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="relative min-w-max">
|
|
|
|
|
<!-- Date axis header: month row on top, date-number row below -->
|
|
|
|
|
<div class="flex sticky top-0 z-30 bg-background border-b">
|
|
|
|
|
<div class="w-56 h-11 flex-shrink-0 border-r sticky left-0 z-10 bg-background px-3 flex items-center text-xs font-medium text-muted-foreground">
|
|
|
|
|
<div v-else class="flex-1 flex overflow-hidden">
|
|
|
|
|
<!-- Frozen pane: Task Type/Task + Task Status columns, vertical scroll only -->
|
|
|
|
|
<div
|
|
|
|
|
ref="frozenPaneRef"
|
|
|
|
|
class="no-scrollbar overflow-y-auto overflow-x-hidden flex-shrink-0 border-r"
|
|
|
|
|
:style="{ width: FROZEN_WIDTH + 'px' }"
|
|
|
|
|
@scroll="handleFrozenScroll"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
|
|
|
|
<div class="border-r px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
|
|
|
|
Task Type / Task
|
|
|
|
|
</div>
|
|
|
|
|
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
|
|
|
|
|
<div class="px-3 flex items-center text-xs font-medium text-muted-foreground flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
|
|
|
|
Task Status
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
|
|
|
|
<div
|
|
|
|
|
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
|
|
|
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
|
|
|
|
@click="toggleGroup(group.taskType)"
|
|
|
|
|
>
|
|
|
|
|
<div class="border-r px-3 text-xs font-medium flex items-center gap-1 self-stretch flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
|
|
|
|
<component
|
|
|
|
|
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
|
|
|
|
|
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
|
|
|
|
/>
|
|
|
|
|
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
|
|
|
|
|
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template v-if="!isCollapsed(group.taskType)">
|
|
|
|
|
<div
|
|
|
|
|
v-for="task in group.tasks"
|
|
|
|
|
:key="task.id"
|
|
|
|
|
class="group flex items-center border-b hover:bg-muted/30"
|
|
|
|
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
|
|
|
|
>
|
|
|
|
|
<div class="border-r pl-8 pr-3 text-xs truncate self-stretch flex items-center flex-shrink-0" :style="{ width: LABEL_COLUMN_WIDTH + 'px' }">
|
|
|
|
|
{{ task.shot_name || task.asset_name || task.name }}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
|
|
|
|
<EditableTaskStatus
|
|
|
|
|
:task-id="task.id"
|
|
|
|
|
:status="task.status"
|
|
|
|
|
:project-id="projectId"
|
|
|
|
|
@status-updated="handleStatusUpdated"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Timeline pane: date axis + bars, scrolls both ways -->
|
|
|
|
|
<div ref="timelinePaneRef" class="flex-1 overflow-auto" @scroll="handleTimelineScroll">
|
|
|
|
|
<div class="relative" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
|
|
|
|
|
<!-- Date axis header: month row on top, date-number row below -->
|
|
|
|
|
<div class="sticky top-0 z-10 bg-background border-b" :style="{ height: HEADER_HEIGHT + 'px' }">
|
|
|
|
|
<div class="relative h-5 border-b">
|
|
|
|
|
<div
|
|
|
|
|
v-for="marker in topAxisMarkers"
|
|
|
|
@@ -105,49 +160,36 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Weekend shading -->
|
|
|
|
|
<div
|
|
|
|
|
v-for="col in weekendColumns"
|
|
|
|
|
:key="col.left"
|
|
|
|
|
class="absolute top-0 bottom-0 pointer-events-none"
|
|
|
|
|
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
|
|
|
|
|
:style="{ left: col.left + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<!-- Rows -->
|
|
|
|
|
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
|
|
|
|
<div
|
|
|
|
|
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
|
|
|
|
class="relative border-b bg-muted/40 cursor-pointer"
|
|
|
|
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
|
|
|
|
@click="toggleGroup(group.taskType)"
|
|
|
|
|
>
|
|
|
|
|
<div class="w-56 flex-shrink-0 border-r px-3 py-2 text-xs font-medium flex items-center gap-1 sticky left-0 z-10 bg-muted/40">
|
|
|
|
|
<component
|
|
|
|
|
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
|
|
|
|
|
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
|
|
|
|
/>
|
|
|
|
|
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
|
|
|
|
|
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
|
|
|
|
|
<div
|
|
|
|
|
v-if="group.barLeft !== null"
|
|
|
|
|
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
|
|
|
|
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template v-if="!isCollapsed(group.taskType)">
|
|
|
|
|
<div
|
|
|
|
|
v-for="task in group.tasks"
|
|
|
|
|
:key="task.id"
|
|
|
|
|
class="group flex items-center border-b hover:bg-muted/30"
|
|
|
|
|
class="relative border-b hover:bg-muted/30"
|
|
|
|
|
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
|
|
|
|
>
|
|
|
|
|
<div class="w-56 flex-shrink-0 border-r pl-8 pr-3 py-1.5 text-xs truncate sticky left-0 z-10 bg-background group-hover:bg-muted/30">
|
|
|
|
|
{{ task.shot_name || task.asset_name || task.name }}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
|
|
|
|
|
<div
|
|
|
|
|
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
|
|
|
|
|
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
|
|
|
|
@@ -176,7 +218,14 @@
|
|
|
|
|
{{ task.shot_name || task.asset_name || task.name }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Submission date markers -->
|
|
|
|
|
<div
|
|
|
|
|
v-for="date in submissionDatesFor(task.id)"
|
|
|
|
|
:key="date"
|
|
|
|
|
class="absolute top-1/2 h-2 w-2 rounded-full bg-white border border-slate-500 pointer-events-none"
|
|
|
|
|
:style="{ left: dateToLeft(parseDate(date)) + 'px', transform: 'translate(-50%, -50%)' }"
|
|
|
|
|
:title="`Submitted ${formatDate(date)}`"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
@@ -185,7 +234,7 @@
|
|
|
|
|
<div
|
|
|
|
|
v-if="todayLeft !== null"
|
|
|
|
|
class="absolute top-0 bottom-0 w-px pointer-events-none"
|
|
|
|
|
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
|
|
|
|
|
:style="{ left: todayLeft + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
|
|
|
|
|
>
|
|
|
|
|
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
|
|
|
|
|
Today
|
|
|
|
@@ -193,6 +242,8 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
|
|
|
|
|
<TaskDetailPanel
|
|
|
|
@@ -213,7 +264,8 @@ import { DatePicker } from '@/components/ui/date-picker'
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
|
|
|
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
|
|
|
|
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
|
|
|
|
|
import { taskService, type TaskListItem } from '@/services/task'
|
|
|
|
|
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
|
|
|
|
import { taskService, type TaskListItem, type SubmissionDateInfo } from '@/services/task'
|
|
|
|
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
|
|
|
|
import { useDetailPanel } from '@/composables/useDetailPanel'
|
|
|
|
|
import { useToast } from '@/components/ui/toast/use-toast'
|
|
|
|
@@ -236,10 +288,37 @@ const {
|
|
|
|
|
const isLoading = ref(false)
|
|
|
|
|
const error = ref<string | null>(null)
|
|
|
|
|
const tasks = ref<TaskListItem[]>([])
|
|
|
|
|
const submissionDates = ref<SubmissionDateInfo[]>([])
|
|
|
|
|
const episodeFilter = ref<number | null>(null)
|
|
|
|
|
const collapsedGroups = ref<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const LABEL_COLUMN_WIDTH = 224 // matches w-56
|
|
|
|
|
const STATUS_COLUMN_WIDTH = 176 // matches w-44
|
|
|
|
|
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
|
|
|
|
|
const HEADER_HEIGHT = 44
|
|
|
|
|
const GROUP_ROW_HEIGHT = 32
|
|
|
|
|
const TASK_ROW_HEIGHT = 36
|
|
|
|
|
|
|
|
|
|
// The frozen (Task Type/Task Status) pane and the timeline pane are two
|
|
|
|
|
// independently-scrolled elements so the horizontal scrollbar only ever
|
|
|
|
|
// spans the timeline. Vertical scroll position is kept in sync between them.
|
|
|
|
|
const frozenPaneRef = ref<HTMLElement | null>(null)
|
|
|
|
|
const timelinePaneRef = ref<HTMLElement | null>(null)
|
|
|
|
|
let syncingScroll = false
|
|
|
|
|
|
|
|
|
|
function handleFrozenScroll() {
|
|
|
|
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
|
|
|
|
syncingScroll = true
|
|
|
|
|
timelinePaneRef.value.scrollTop = frozenPaneRef.value.scrollTop
|
|
|
|
|
syncingScroll = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleTimelineScroll() {
|
|
|
|
|
if (syncingScroll || !frozenPaneRef.value || !timelinePaneRef.value) return
|
|
|
|
|
syncingScroll = true
|
|
|
|
|
frozenPaneRef.value.scrollTop = timelinePaneRef.value.scrollTop
|
|
|
|
|
syncingScroll = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SCALES = ['day', 'week', 'month'] as const
|
|
|
|
|
type ViewScale = typeof SCALES[number]
|
|
|
|
@@ -290,6 +369,33 @@ async function loadTasks() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadSubmissionDates() {
|
|
|
|
|
try {
|
|
|
|
|
submissionDates.value = await taskService.getSubmissionDates(props.projectId)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to load submission dates:', err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// task_id -> deduplicated YYYY-MM-DD submission dates, for the white dot markers on each bar.
|
|
|
|
|
const submissionDatesByTask = computed(() => {
|
|
|
|
|
const map = new Map<number, string[]>()
|
|
|
|
|
for (const s of submissionDates.value) {
|
|
|
|
|
const day = s.submitted_at.slice(0, 10)
|
|
|
|
|
const existing = map.get(s.task_id)
|
|
|
|
|
if (existing) {
|
|
|
|
|
if (!existing.includes(day)) existing.push(day)
|
|
|
|
|
} else {
|
|
|
|
|
map.set(s.task_id, [day])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function submissionDatesFor(taskId: number): string[] {
|
|
|
|
|
return submissionDatesByTask.value.get(taskId) || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const episodeOptions = computed(() => {
|
|
|
|
|
const map = new Map<number, string>()
|
|
|
|
|
for (const t of tasks.value) {
|
|
|
|
@@ -497,6 +603,11 @@ function isTaskActive(task: TaskListItem): boolean {
|
|
|
|
|
return selectedTask.value?.id === task.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStatusUpdated(taskId: number, newStatus: string) {
|
|
|
|
|
const task = tasks.value.find(t => t.id === taskId)
|
|
|
|
|
if (task) task.status = newStatus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Drag to reschedule ---
|
|
|
|
|
|
|
|
|
|
interface DragState {
|
|
|
|
@@ -620,6 +731,7 @@ function openTask(taskId: number) {
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
loadTasks()
|
|
|
|
|
loadSubmissionDates()
|
|
|
|
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
@@ -634,6 +746,18 @@ watch(() => props.projectId, () => {
|
|
|
|
|
manualRangeStart.value = ''
|
|
|
|
|
manualRangeEnd.value = ''
|
|
|
|
|
loadTasks()
|
|
|
|
|
loadSubmissionDates()
|
|
|
|
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
/* Frozen pane scrolls vertically (kept in sync with the timeline pane) but
|
|
|
|
|
shouldn't show its own scrollbar - the timeline pane's scrollbar is enough. */
|
|
|
|
|
.no-scrollbar {
|
|
|
|
|
scrollbar-width: none;
|
|
|
|
|
}
|
|
|
|
|
.no-scrollbar::-webkit-scrollbar {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|