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' }} {{ 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)