Add project Schedule page with interactive Gantt chart

Adds a Schedule tab (Kitsu-style production schedule) under each
project: tasks grouped by type with drag-to-reschedule bars, Day/Week/
Month zoom, manual date-range control, weekend shading, a frozen task
column, and a two-tier month/date axis header. Requires a new
start_date field on Task (start_date was previously missing; only
deadline existed) and shadcn DatePicker inputs replace native date
inputs on the Schedule toolbar and TaskDetailPanel's Start Date/
Deadline fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 04:10:43 +08:00
parent 981808b901
commit f547d05478
11 changed files with 967 additions and 19 deletions
@@ -123,12 +123,26 @@
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
</p>
</div>
<div></div>
<div>
<Label class="text-muted-foreground">Start Date</Label>
<div class="mt-1">
<DatePicker
v-model="localStartDate"
placeholder="Set start date"
@update:model-value="(val) => handleDateChange('start_date', val || '')"
/>
</div>
</div>
<div>
<Label class="text-muted-foreground">Deadline</Label>
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)">
<Calendar class="h-3 w-3" />
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }}
</p>
<div class="mt-1">
<DatePicker
v-model="localDeadline"
placeholder="Set deadline"
@update:model-value="(val) => handleDateChange('deadline', val || '')"
/>
</div>
</div>
</div>
<div>
@@ -284,10 +298,11 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { Play, Upload, UserPlus, Calendar, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next'
import { Play, Upload, UserPlus, User, Info, MessageSquare, Paperclip } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { DatePicker } from '@/components/ui/date-picker'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
@@ -348,6 +363,8 @@ const task = ref<Task | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const localStatus = ref('')
const localStartDate = ref('')
const localDeadline = ref('')
const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([])
const submissions = ref<Submission[]>([])
@@ -381,6 +398,8 @@ async function loadTask() {
try {
task.value = await taskService.getTask(props.taskId)
localStatus.value = task.value.status
localStartDate.value = task.value.start_date || ''
localDeadline.value = task.value.deadline || ''
} catch (err: any) {
console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task'
@@ -440,6 +459,30 @@ async function handleStatusChange(newStatus: string) {
}
}
async function handleDateChange(field: 'start_date' | 'deadline', value: string) {
if (!task.value) return
const previous = task.value[field]
try {
const updated = await taskService.updateTask(props.taskId, { [field]: value || null } as any)
task.value[field] = updated[field]
emit('taskUpdated')
toast({
title: 'Success',
description: `Task ${field === 'start_date' ? 'start date' : 'deadline'} updated successfully`
})
} catch (error: any) {
console.error(`Error updating ${field}:`, error)
if (field === 'start_date') localStartDate.value = previous || ''
else localDeadline.value = previous || ''
toast({
title: 'Error',
description: error.response?.data?.detail || `Failed to update ${field === 'start_date' ? 'start date' : 'deadline'}`,
variant: 'destructive'
})
}
}
async function handleQuickAction(action: 'start' | 'submit') {
if (!task.value) return
@@ -519,19 +562,6 @@ function formatDate(dateString: string): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function getDeadlineClass(deadline: string | undefined, status: string): string {
if (!deadline || status === 'approved') return 'text-muted-foreground'
const now = new Date()
const deadlineDate = new Date(deadline)
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntil < 0) return 'text-destructive'
if (daysUntil <= 3) return 'text-orange-600'
if (daysUntil <= 7) return 'text-yellow-600'
return 'text-foreground'
}
watch(() => props.taskId, () => {
loadTask()
loadNotes()