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:
2026-07-21 03:34:19 +08:00
parent 7f260067a2
commit 11e1369f2f
@@ -20,6 +20,19 @@
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</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">
<Button
v-for="scale in SCALES"
@@ -73,7 +86,7 @@
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</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.
</div>
@@ -94,24 +107,24 @@
</div>
</div>
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div v-for="group in rowGroups" :key="group.key">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
: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' }">
<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"
/>
<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>
</div>
<div class="flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }"></div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
:key="task.id"
@@ -119,7 +132,7 @@
: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 }}
{{ taskRowLabel(task) }}
</div>
<div class="border-r px-3 flex items-center self-stretch flex-shrink-0" :style="{ width: STATUS_COLUMN_WIDTH + 'px' }">
<EditableTaskStatus
@@ -170,11 +183,11 @@
></div>
<!-- Rows -->
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div v-for="group in rowGroups" :key="group.key">
<div
class="relative border-b bg-muted/40 cursor-pointer"
:style="{ height: GROUP_ROW_HEIGHT + 'px' }"
@click="toggleGroup(group.taskType)"
@click="toggleGroup(group.key)"
>
<div
v-if="group.barLeft !== null"
@@ -183,7 +196,7 @@
></div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<template v-if="!isCollapsed(group.key)">
<div
v-for="task in group.tasks"
: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="isTaskActive(task) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
>
{{ task.shot_name || task.asset_name || task.name }}
{{ taskRowLabel(task) }}
</span>
</div>
<!-- Submission date markers -->
@@ -539,23 +552,43 @@ const todayLeft = computed(() => {
return dateToLeft(todayUtc)
})
interface TaskTypeGroup {
taskType: string
const GROUP_BY_OPTIONS = ['taskType', 'shot'] as const
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[]
barLeft: number | null
barWidth: number | null
}
const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
const byType = new Map<string, TaskListItem[]>()
const rowGroups = computed<RowGroup[]>(() => {
const byKey = new Map<string, TaskListItem[]>()
for (const t of scheduledTasks.value) {
if (!byType.has(t.task_type)) byType.set(t.task_type, [])
byType.get(t.task_type)!.push(t)
const key = groupBy.value === 'taskType' ? t.task_type : entityName(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]))
.map(([taskType, groupTasks]) => {
.map(([key, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
@@ -571,18 +604,19 @@ const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
barLeft = dateToLeft(minStart!)
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 {
return collapsedGroups.value.has(taskType)
function isCollapsed(key: string): boolean {
return collapsedGroups.value.has(key)
}
function toggleGroup(taskType: string) {
function toggleGroup(key: string) {
const next = new Set(collapsedGroups.value)
if (next.has(taskType)) next.delete(taskType)
else next.add(taskType)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedGroups.value = next
}
@@ -591,9 +625,13 @@ const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? 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 {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
@@ -745,6 +783,7 @@ watch(() => props.projectId, () => {
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
groupBy.value = 'taskType'
loadTasks()
loadSubmissionDates()
taskStatusesStore.fetchProjectStatuses(props.projectId)