diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 84e94cc..42ee6df 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -19,7 +19,7 @@ from schemas.task import ( TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment, ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse, TaskAttachmentCreate, TaskAttachmentResponse, - SubmissionCreate, SubmissionUpdate, SubmissionResponse, + SubmissionCreate, SubmissionUpdate, SubmissionResponse, SubmissionDateInfo, BulkStatusUpdate, BulkAssignment, BulkActionResult ) from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission @@ -682,6 +682,26 @@ async def bulk_assign_tasks( ) +@router.get("/submission-dates", response_model=List[SubmissionDateInfo]) +async def get_submission_dates( + project_id: int = Query(..., description="Project ID to fetch submission dates for"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get a lightweight list of (task_id, submitted_at) for every non-deleted + submission on a project's tasks, for rendering submission markers (e.g. + on the Schedule Gantt chart) without fetching full submission payloads. + """ + submissions = db.query(Submission.task_id, Submission.submitted_at).join(Task).filter( + Task.project_id == project_id, + Task.deleted_at.is_(None), + Submission.deleted_at.is_(None) + ).all() + + return [SubmissionDateInfo(task_id=task_id, submitted_at=submitted_at) for task_id, submitted_at in submissions] + + @router.get("/{task_id}", response_model=TaskResponse) async def get_task( task_id: int, diff --git a/backend/schemas/task.py b/backend/schemas/task.py index b823e8e..54bab00 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -195,6 +195,15 @@ class SubmissionResponse(SubmissionBase): from_attributes = True +class SubmissionDateInfo(BaseModel): + """Minimal per-task submission date, for lightweight bulk lookups (e.g. Gantt markers).""" + task_id: int + submitted_at: datetime + + class Config: + from_attributes = True + + # Review schemas class ReviewBase(BaseModel): decision: ReviewDecision diff --git a/frontend/src/components/schedule/ScheduleGantt.vue b/frontend/src/components/schedule/ScheduleGantt.vue index 68c98f7..5b13e22 100644 --- a/frontend/src/components/schedule/ScheduleGantt.vue +++ b/frontend/src/components/schedule/ScheduleGantt.vue @@ -67,129 +67,180 @@
{{ error }}
-
-
+
+
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline {{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
-
+
No scheduled tasks to display yet. Set a start date and deadline on a task to see it here.
-
- -
-
- Task Type / Task +
+ +
+
+
+ Task Type / Task +
+
+ Task Status +
-
-
-
- {{ marker.label }} + +
+
+
+ + {{ formatTaskType(group.taskType) }} + ({{ group.tasks.length }})
+
-
+ +
- -
- - -
-
-
- - {{ formatTaskType(group.taskType) }} - ({{ group.tasks.length }}) -
-
-
-
-
- - -
- -
- - Today - + +
+ + +
+
+
+
+ + +
+ + +
+ + Today + +
+
@@ -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(null) const tasks = ref([]) +const submissionDates = ref([]) const episodeFilter = ref(null) const collapsedGroups = ref>(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(null) +const timelinePaneRef = ref(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() + 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() 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) }) + + diff --git a/frontend/src/services/task.ts b/frontend/src/services/task.ts index a872ed8..a6cc86b 100644 --- a/frontend/src/services/task.ts +++ b/frontend/src/services/task.ts @@ -109,6 +109,11 @@ export interface Submission { stream_url?: string } +export interface SubmissionDateInfo { + task_id: number + submitted_at: string +} + export interface TaskFilters { projectId?: number shotId?: number @@ -249,6 +254,11 @@ class TaskService { return response.data } + async getSubmissionDates(projectId: number): Promise { + const response = await apiClient.get(`/tasks/submission-dates?project_id=${projectId}`) + return response.data + } + async submitWork(taskId: number, file: File, notes?: string): Promise { const formData = new FormData() formData.append('file', file)