Add group-by toggle (Task Type / Shot) to the Schedule Gantt chart
Generalizes the previous task-type-only grouping into rowGroups, keyed by either task_type or the shot/asset name depending on a new toolbar toggle. Each row's label shows whichever dimension isn't already the group header, so switching modes stays readable instead of showing redundant text. Collapse state resets on mode/project change to avoid stale group keys. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,19 @@
|
|||||||
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
|
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 border rounded-md p-0.5">
|
||||||
|
<Button
|
||||||
|
v-for="option in GROUP_BY_OPTIONS"
|
||||||
|
:key="option"
|
||||||
|
:variant="groupBy === option ? 'secondary' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
@click="groupBy = option"
|
||||||
|
>
|
||||||
|
{{ groupByLabel(option) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 border rounded-md p-0.5">
|
<div class="flex items-center gap-1 border rounded-md p-0.5">
|
||||||
<Button
|
<Button
|
||||||
v-for="scale in SCALES"
|
v-for="scale in SCALES"
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="taskTypeGroups.length === 0" class="flex-1 p-12 text-center text-sm text-muted-foreground">
|
<div v-if="rowGroups.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.
|
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,24 +107,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
<div v-for="group in rowGroups" :key="group.key">
|
||||||
<div
|
<div
|
||||||
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
|
||||||
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
@click="toggleGroup(group.taskType)"
|
@click="toggleGroup(group.key)"
|
||||||
>
|
>
|
||||||
<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' }">
|
<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
|
<component
|
||||||
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
|
:is="isCollapsed(group.key) ? ChevronRight : ChevronDown"
|
||||||
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
|
<span class="truncate">{{ group.label }}</span>
|
||||||
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
|
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!isCollapsed(group.taskType)">
|
<template v-if="!isCollapsed(group.key)">
|
||||||
<div
|
<div
|
||||||
v-for="task in group.tasks"
|
v-for="task in group.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
@@ -119,7 +132,7 @@
|
|||||||
:style="{ height: TASK_ROW_HEIGHT + 'px' }"
|
: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' }">
|
<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 }}
|
{{ taskRowLabel(task) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
|
||||||
<EditableTaskStatus
|
<EditableTaskStatus
|
||||||
@@ -170,11 +183,11 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
<div v-for="group in taskTypeGroups" :key="group.taskType">
|
<div v-for="group in rowGroups" :key="group.key">
|
||||||
<div
|
<div
|
||||||
class="relative border-b bg-muted/40 cursor-pointer"
|
class="relative border-b bg-muted/40 cursor-pointer"
|
||||||
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
|
||||||
@click="toggleGroup(group.taskType)"
|
@click="toggleGroup(group.key)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="group.barLeft !== null"
|
v-if="group.barLeft !== null"
|
||||||
@@ -183,7 +196,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!isCollapsed(group.taskType)">
|
<template v-if="!isCollapsed(group.key)">
|
||||||
<div
|
<div
|
||||||
v-for="task in group.tasks"
|
v-for="task in group.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
@@ -215,7 +228,7 @@
|
|||||||
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
|
class="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 text-[10px] text-foreground whitespace-nowrap pointer-events-none transition-opacity"
|
||||||
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
:class="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||||
>
|
>
|
||||||
{{ task.shot_name || task.asset_name || task.name }}
|
{{ taskRowLabel(task) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Submission date markers -->
|
<!-- Submission date markers -->
|
||||||
@@ -539,23 +552,43 @@ const todayLeft = computed(() => {
|
|||||||
return dateToLeft(todayUtc)
|
return dateToLeft(todayUtc)
|
||||||
})
|
})
|
||||||
|
|
||||||
interface TaskTypeGroup {
|
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
|
||||||
taskType: string
|
type GroupBy = typeof GROUP_BY_OPTIONS[number]
|
||||||
|
const groupBy = ref<GroupBy>('taskType')
|
||||||
|
|
||||||
|
function groupByLabel(mode: GroupBy): string {
|
||||||
|
return mode === 'taskType' ? 'Task Type' : 'Shot'
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityName(task: TaskListItem): string {
|
||||||
|
return task.shot_name || task.asset_name || task.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row label shows whichever dimension ISN'T already the group header:
|
||||||
|
// task type when grouped by shot, and the shot/asset name when grouped by task type.
|
||||||
|
function taskRowLabel(task: TaskListItem): string {
|
||||||
|
return groupBy.value === 'taskType' ? entityName(task) : formatTaskType(task.task_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
tasks: TaskListItem[]
|
tasks: TaskListItem[]
|
||||||
barLeft: number | null
|
barLeft: number | null
|
||||||
barWidth: number | null
|
barWidth: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
|
const rowGroups = computed<RowGroup[]>(() => {
|
||||||
const byType = new Map<string, TaskListItem[]>()
|
const byKey = new Map<string, TaskListItem[]>()
|
||||||
for (const t of scheduledTasks.value) {
|
for (const t of scheduledTasks.value) {
|
||||||
if (!byType.has(t.task_type)) byType.set(t.task_type, [])
|
const key = groupBy.value === 'taskType' ? t.task_type : entityName(t)
|
||||||
byType.get(t.task_type)!.push(t)
|
if (!byKey.has(key)) byKey.set(key, [])
|
||||||
|
byKey.get(key)!.push(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(byType.entries())
|
return Array.from(byKey.entries())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.map(([taskType, groupTasks]) => {
|
.map(([key, groupTasks]) => {
|
||||||
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
|
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
|
||||||
let barLeft: number | null = null
|
let barLeft: number | null = null
|
||||||
let barWidth: number | null = null
|
let barWidth: number | null = null
|
||||||
@@ -571,18 +604,19 @@ const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
|
|||||||
barLeft = dateToLeft(minStart!)
|
barLeft = dateToLeft(minStart!)
|
||||||
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
|
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
|
||||||
}
|
}
|
||||||
return { taskType, tasks: sorted, barLeft, barWidth }
|
const label = groupBy.value === 'taskType' ? formatTaskType(key) : key
|
||||||
|
return { key, label, tasks: sorted, barLeft, barWidth }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function isCollapsed(taskType: string): boolean {
|
function isCollapsed(key: string): boolean {
|
||||||
return collapsedGroups.value.has(taskType)
|
return collapsedGroups.value.has(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(taskType: string) {
|
function toggleGroup(key: string) {
|
||||||
const next = new Set(collapsedGroups.value)
|
const next = new Set(collapsedGroups.value)
|
||||||
if (next.has(taskType)) next.delete(taskType)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(taskType)
|
else next.add(key)
|
||||||
collapsedGroups.value = next
|
collapsedGroups.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,9 +625,13 @@ const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
|
|||||||
function toggleAllGroups() {
|
function toggleAllGroups() {
|
||||||
collapsedGroups.value = hasCollapsedGroups.value
|
collapsedGroups.value = hasCollapsedGroups.value
|
||||||
? new Set()
|
? new Set()
|
||||||
: new Set(taskTypeGroups.value.map(g => g.taskType))
|
: new Set(rowGroups.value.map(g => g.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(groupBy, () => {
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
})
|
||||||
|
|
||||||
function statusColor(task: TaskListItem): string {
|
function statusColor(task: TaskListItem): string {
|
||||||
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
|
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
|
||||||
return status?.color || '#94A3B8'
|
return status?.color || '#94A3B8'
|
||||||
@@ -745,6 +783,7 @@ watch(() => props.projectId, () => {
|
|||||||
collapsedGroups.value = new Set()
|
collapsedGroups.value = new Set()
|
||||||
manualRangeStart.value = ''
|
manualRangeStart.value = ''
|
||||||
manualRangeEnd.value = ''
|
manualRangeEnd.value = ''
|
||||||
|
groupBy.value = 'taskType'
|
||||||
loadTasks()
|
loadTasks()
|
||||||
loadSubmissionDates()
|
loadSubmissionDates()
|
||||||
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
|
|||||||
Reference in New Issue
Block a user