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
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
One-off script to generate example start_date/deadline data on shot tasks,
for testing/demoing the Project Schedule (Gantt) page.
Reuses the same shot-sampling approach as generate_note_example_data.py: the
dev database is heavily populated with disposable load-test fixtures, so this
picks a small, bounded sample of shots (from Dragon Quest) that have the
normal 4-task set, rather than scheduling all ~1400 shot tasks.
For each sampled shot, tasks are scheduled in pipeline order (layout ->
animation -> simulation -> lighting -> compositing, any other task types
appended after) with realistic overlapping durations, staggered around
today's date so the chart's "Today" marker falls inside the visible range.
Idempotent: skips any task that already has a start_date set, so it's safe
to re-run without clobbering manually-edited dates.
"""
from datetime import date, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.shot import Shot
from models.task import Task
from models.project import Project
import logging
import random
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PROJECT_NAME = "Dragon Quest"
SAMPLE_SIZE = 15
RNG_SEED = 42
# Canonical pipeline order used to sequence a shot's tasks; anything not
# listed here keeps its natural (query) order, appended at the end.
PIPELINE_ORDER = ["layout", "animation", "simulation", "lighting", "compositing", "previz"]
def pipeline_index(task_type: str) -> int:
return PIPELINE_ORDER.index(task_type) if task_type in PIPELINE_ORDER else len(PIPELINE_ORDER)
def generate_schedule_example_data():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
rng = random.Random(RNG_SEED)
today = date.today()
try:
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
if not project:
logger.error(f"Project '{PROJECT_NAME}' not found")
return
logger.info(f"Project '{PROJECT_NAME}' (id={project.id})")
# Shots with the normal 4-task set (excludes shots with zero tasks
# and the handful of outliers with extra bulk-test tasks piled on).
shots = db.query(Shot).filter(
Shot.project_id == project.id, Shot.deleted_at.is_(None)
).order_by(Shot.id).all()
candidates = [
s for s in shots
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
]
sample_shots = candidates[:SAMPLE_SIZE]
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
tasks_scheduled = 0
tasks_skipped = 0
for shot in sample_shots:
tasks = db.query(Task).filter(
Task.shot_id == shot.id, Task.deleted_at.is_(None)
).all()
tasks.sort(key=lambda t: pipeline_index(t.task_type))
# Stagger each shot's pipeline start across a window that straddles
# today, so the demo chart shows a mix of past/current/future work.
shot_start = today + timedelta(days=rng.randint(-20, 20))
cursor = shot_start
for task in tasks:
if task.start_date is not None:
tasks_skipped += 1
# Still advance the cursor so later tasks in this shot
# don't bunch up if an earlier one was already scheduled.
cursor = task.deadline or cursor
continue
duration = rng.randint(4, 12)
task.start_date = cursor
task.deadline = cursor + timedelta(days=duration)
tasks_scheduled += 1
# Next task starts a little before this one ends (realistic
# pipeline overlap) rather than strictly back-to-back.
cursor = task.deadline - timedelta(days=rng.randint(0, 4))
db.commit()
logger.info(f"Tasks scheduled: {tasks_scheduled}, skipped (already scheduled): {tasks_skipped}")
except Exception as e:
logger.error(f"Generation failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Generating schedule example data...")
generate_schedule_example_data()
logger.info("Done!")
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
Migration script to add the start_date column to the tasks table.
Usage:
python migrate_task_start_date.py
"""
import sqlite3
import sys
from pathlib import Path
def get_database_path():
"""Get the database path, trying multiple possible locations."""
possible_paths = [
"vfx_project_management.db", # Primary database
"database.db",
"../vfx_project_management.db"
]
for path in possible_paths:
if Path(path).exists():
return path
return "vfx_project_management.db"
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table."""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Add start_date column to the tasks table."""
db_path = get_database_path()
print(f"Using database: {db_path}")
conn = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
if not cursor.fetchone():
print("Tasks table not found. Creating new database schema...")
conn.close()
return
if check_column_exists(cursor, "tasks", "start_date"):
print("Column start_date already exists, skipping...")
else:
print("Adding column: start_date")
cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
conn.commit()
cursor.execute("SELECT COUNT(*) FROM tasks")
task_count = cursor.fetchone()[0]
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
except sqlite3.Error as e:
print(f"Database error: {e}")
if conn:
conn.rollback()
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
if conn:
conn.rollback()
sys.exit(1)
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("VFX Project Management - Task Start Date Migration")
print("=" * 60)
migrate_database()
print("\nMigration completed successfully!")
+1
View File
@@ -56,6 +56,7 @@ class Task(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+6
View File
@@ -248,6 +248,7 @@ async def get_tasks(
"name": task.name,
"task_type": task.task_type,
"status": task.status,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"project_name": task.project.name if task.project else None,
@@ -329,6 +330,7 @@ async def get_my_tasks(
name=task.name,
task_type=task.task_type,
status=task.status,
start_date=task.start_date,
deadline=task.deadline,
project_id=task.project_id,
project_name=task.project.name if task.project else "Unknown",
@@ -718,6 +720,7 @@ async def get_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -843,6 +846,7 @@ async def update_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -934,6 +938,7 @@ async def update_task_status(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -1039,6 +1044,7 @@ async def assign_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
"start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
+3
View File
@@ -11,6 +11,7 @@ class TaskBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
task_type: str # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
@@ -27,6 +28,7 @@ class TaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
start_date: Optional[date] = None
deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
assigned_user_id: Optional[int] = None
@@ -68,6 +70,7 @@ class TaskListResponse(BaseModel):
name: str
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
start_date: Optional[date] = None
deadline: Optional[date] = None
project_id: int
project_name: str
@@ -182,6 +182,7 @@ import {
Package,
ListTodo,
ShieldCheck,
GanttChartSquare,
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
@@ -221,6 +222,7 @@ const projectTabs = computed(() => {
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
{ id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` },
{ id: 'schedule', label: 'Schedule', icon: GanttChartSquare, route: `/projects/${id}/schedule` },
{ id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` },
]
})
@@ -233,6 +235,7 @@ const activeProjectTab = computed(() => {
if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule'
if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
return null
})
@@ -0,0 +1,639 @@
<template>
<div class="h-full flex flex-col">
<!-- Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 border-b">
<div class="flex flex-wrap items-center gap-3">
<Select
:model-value="episodeFilter === null ? 'all' : String(episodeFilter)"
@update:model-value="handleEpisodeFilterChange"
>
<SelectTrigger class="w-48 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Episodes</SelectItem>
<SelectItem v-for="ep in episodeOptions" :key="ep.id" :value="String(ep.id)">{{ ep.name }}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-8" @click="toggleAllGroups">
{{ hasCollapsedGroups ? 'Expand All' : 'Collapse All' }}
</Button>
<div class="flex items-center gap-1 border rounded-md p-0.5">
<Button
v-for="scale in SCALES"
:key="scale"
:variant="viewScale === scale ? 'secondary' : 'ghost'"
size="sm"
class="h-7 px-2 text-xs capitalize"
@click="viewScale = scale"
>
{{ scale }}
</Button>
</div>
<div class="flex items-center gap-1.5">
<div class="w-36">
<DatePicker v-model="manualRangeStart" placeholder="Start date" />
</div>
<span class="text-xs text-muted-foreground">to</span>
<div class="w-36">
<DatePicker v-model="manualRangeEnd" placeholder="End date" :min="manualRangeStart" />
</div>
<Button
v-if="manualRangeStart || manualRangeEnd"
variant="ghost"
size="sm"
class="h-8 px-2 text-xs"
@click="manualRangeStart = ''; manualRangeEnd = ''"
>
Reset
</Button>
</div>
</div>
<div v-if="legendStatuses.length > 0" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span v-for="status in legendStatuses" :key="status.id" class="flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm flex-shrink-0" :style="{ backgroundColor: status.color }"></span>
{{ status.name }}
</span>
</div>
</div>
<div v-if="isLoading" class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Loading schedule...
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center text-sm text-destructive">
{{ error }}
</div>
<div v-else class="flex-1 overflow-auto">
<div v-if="unscheduledCount > 0" class="px-4 sm:px-6 py-2 text-xs text-muted-foreground border-b bg-muted/30">
{{ unscheduledCount }} task{{ unscheduledCount === 1 ? '' : 's' }} without both a start date and deadline
{{ unscheduledCount === 1 ? "isn't" : "aren't" }} shown on the chart.
</div>
<div v-if="taskTypeGroups.length === 0" class="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>
<div v-else class="relative min-w-max">
<!-- Date axis header: month row on top, date-number row below -->
<div class="flex sticky top-0 z-30 bg-background border-b">
<div class="w-56 h-11 flex-shrink-0 border-r sticky left-0 z-10 bg-background px-3 flex items-center text-xs font-medium text-muted-foreground">
Task Type / Task
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px' }">
<div class="relative h-5 border-b">
<div
v-for="marker in topAxisMarkers"
:key="'month-' + marker.left"
class="absolute top-0 bottom-0 border-l px-1.5 flex items-center overflow-hidden text-[10px] font-medium text-muted-foreground whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width + 'px' }"
>
{{ marker.label }}
</div>
</div>
<div class="relative h-6">
<div
v-for="marker in axisMarkers"
:key="'day-' + marker.left"
class="absolute top-0 bottom-0 border-l flex items-center overflow-hidden text-[10px] text-muted-foreground px-1.5 whitespace-nowrap"
:style="{ left: marker.left + 'px', width: marker.width ? marker.width + 'px' : undefined }"
>
{{ marker.label }}
</div>
</div>
</div>
</div>
<!-- Weekend shading -->
<div
v-for="col in weekendColumns"
:key="col.left"
class="absolute top-0 bottom-0 pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + col.left) + 'px', width: pixelsPerDay + 'px', backgroundColor: 'hsl(var(--muted))' }"
></div>
<!-- Rows -->
<div v-for="group in taskTypeGroups" :key="group.taskType">
<div
class="flex items-center border-b bg-muted/40 cursor-pointer select-none"
@click="toggleGroup(group.taskType)"
>
<div class="w-56 flex-shrink-0 border-r px-3 py-2 text-xs font-medium flex items-center gap-1 sticky left-0 z-10 bg-muted/40">
<component
:is="isCollapsed(group.taskType) ? ChevronRight : ChevronDown"
class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
/>
<span class="truncate">{{ formatTaskType(group.taskType) }}</span>
<span class="text-muted-foreground flex-shrink-0">({{ group.tasks.length }})</span>
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<div
v-if="group.barLeft !== null"
class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
:style="{ left: group.barLeft + 'px', width: group.barWidth + 'px', backgroundColor: 'rgba(100, 116, 139, 0.5)' }"
></div>
</div>
</div>
<template v-if="!isCollapsed(group.taskType)">
<div
v-for="task in group.tasks"
:key="task.id"
class="group flex items-center border-b hover:bg-muted/30"
>
<div class="w-56 flex-shrink-0 border-r pl-8 pr-3 py-1.5 text-xs truncate sticky left-0 z-10 bg-background group-hover:bg-muted/30">
{{ task.shot_name || task.asset_name || task.name }}
</div>
<div class="relative flex-1" :style="{ width: chartWidth + 'px', minWidth: chartWidth + 'px', height: '32px' }">
<div
class="group absolute top-1/2 -translate-y-1/2 h-4 rounded hover:brightness-90 transition-[filter]"
:class="{ 'ring-2 ring-primary': isTaskActive(task) }"
:style="taskBarStyle(task)"
:title="taskBarTitle(task)"
>
<div
class="absolute inset-y-0 left-0 cursor-grab active:cursor-grabbing"
style="right: 6px;"
@mousedown="startDrag($event, task, 'move')"
></div>
<div
class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-l"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-start')"
></div>
<div
class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 group-hover:opacity-100 rounded-r"
style="background-color: rgba(0, 0, 0, 0.2);"
@mousedown="startDrag($event, task, 'resize-end')"
></div>
<span
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 }}
</span>
</div>
</div>
</div>
</template>
</div>
<!-- Today marker -->
<div
v-if="todayLeft !== null"
class="absolute top-0 bottom-0 w-px pointer-events-none"
:style="{ left: (LABEL_COLUMN_WIDTH + todayLeft) + 'px', backgroundColor: 'rgba(239, 68, 68, 0.7)' }"
>
<span class="absolute top-0 left-1/2 -translate-x-1/2 text-[9px] text-red-500 bg-background px-0.5 whitespace-nowrap">
Today
</span>
</div>
</div>
</div>
<DetailPanelOverlay :visible="!!showPanel" v-model:mobile-open="showMobileDetail">
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask.id"
@close="closeDetailPanel"
@task-updated="loadTasks"
/>
</DetailPanelOverlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
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 { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
projectId: number
}>()
const taskStatusesStore = useTaskStatusesStore()
const { toast } = useToast()
const {
selectedEntity: selectedTask,
showMobileDetail,
showPanel,
closeDetailPanel,
selectEntity: selectTask
} = useDetailPanel<TaskListItem>({ sessionStorageKey: 'scheduleGantt.detailPanelEnabled' })
const isLoading = ref(false)
const error = ref<string | null>(null)
const tasks = ref<TaskListItem[]>([])
const episodeFilter = ref<number | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const LABEL_COLUMN_WIDTH = 224 // matches w-56
const SCALES = ['day', 'week', 'month'] as const
type ViewScale = typeof SCALES[number]
const viewScale = ref<ViewScale>('week')
const SCALE_PIXELS_PER_DAY: Record<ViewScale, number> = { day: 40, week: 22, month: 6 }
const pixelsPerDay = computed(() => SCALE_PIXELS_PER_DAY[viewScale.value])
const manualRangeStart = ref('')
const manualRangeEnd = ref('')
function parseDate(dateStr: string): Date {
return new Date(`${dateStr}T00:00:00Z`)
}
function toDateString(d: Date): string {
return d.toISOString().slice(0, 10)
}
function addDays(d: Date, days: number): Date {
const copy = new Date(d)
copy.setUTCDate(copy.getUTCDate() + days)
return copy
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86400000)
}
function formatTaskType(taskType: string): string {
return taskType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '?'
return parseDate(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
}
async function loadTasks() {
try {
isLoading.value = true
error.value = null
tasks.value = await taskService.getTasks({ projectId: props.projectId, limit: 1000 })
} catch (err: any) {
console.error('Failed to load schedule tasks:', err)
error.value = err.response?.data?.detail || 'Failed to load schedule'
} finally {
isLoading.value = false
}
}
const episodeOptions = computed(() => {
const map = new Map<number, string>()
for (const t of tasks.value) {
if (t.episode_id && t.episode_name && !map.has(t.episode_id)) {
map.set(t.episode_id, t.episode_name)
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
})
function handleEpisodeFilterChange(value: unknown) {
episodeFilter.value = value === 'all' ? null : Number(value)
}
const filteredTasks = computed(() => {
if (episodeFilter.value === null) return tasks.value
return tasks.value.filter(t => t.episode_id === episodeFilter.value)
})
const scheduledTasks = computed(() => filteredTasks.value.filter(t => t.start_date && t.deadline))
const unscheduledCount = computed(() => filteredTasks.value.length - scheduledTasks.value.length)
const autoWindowStart = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let min: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.start_date!)
if (!min || d < min) min = d
}
if (!min) return null
const padded = new Date(min)
padded.setUTCDate(padded.getUTCDate() - 3)
return padded
})
const autoWindowEnd = computed<Date | null>(() => {
if (scheduledTasks.value.length === 0) return null
let max: Date | null = null
for (const t of scheduledTasks.value) {
const d = parseDate(t.deadline!)
if (!max || d > max) max = d
}
if (!max) return null
const padded = new Date(max)
padded.setUTCDate(padded.getUTCDate() + 3)
return padded
})
const windowStart = computed<Date | null>(() => manualRangeStart.value ? parseDate(manualRangeStart.value) : autoWindowStart.value)
const windowEnd = computed<Date | null>(() => manualRangeEnd.value ? parseDate(manualRangeEnd.value) : autoWindowEnd.value)
const chartWidth = computed(() => {
if (!windowStart.value || !windowEnd.value) return 0
return Math.max(daysBetween(windowStart.value, windowEnd.value) * pixelsPerDay.value, 400)
})
function dateToLeft(date: Date): number {
if (!windowStart.value) return 0
return daysBetween(windowStart.value, date) * pixelsPerDay.value
}
// One bounded, window-clipped cell per calendar month touching [windowStart, windowEnd].
function computeMonthSegments(labelOptions: Intl.DateTimeFormatOptions): { left: number; width: number; label: string }[] {
if (!windowStart.value || !windowEnd.value) return []
const segments: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), windowStart.value.getUTCMonth(), 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextMonth = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextMonth), rightBound) - left, 2)
segments.push({ left, width, label: cursor.toLocaleDateString('en-US', labelOptions) })
cursor.setUTCMonth(cursor.getUTCMonth() + 1)
}
return segments
}
const axisMarkers = computed<{ left: number; width?: number; label: string }[]>(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
return computeMonthSegments({ month: 'short', timeZone: 'UTC' })
}
const markers: { left: number; label: string }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
markers.push({
left: dateToLeft(cursor),
label: String(cursor.getUTCDate())
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return markers
})
// Coarser grouping row shown above axisMarkers: month spans (day/week scale) or year spans (month scale).
const topAxisMarkers = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
if (viewScale.value === 'month') {
const markers: { left: number; width: number; label: string }[] = []
const rightBound = chartWidth.value
const cursor = new Date(Date.UTC(windowStart.value.getUTCFullYear(), 0, 1))
while (cursor <= windowEnd.value) {
const segStart = cursor < windowStart.value ? windowStart.value : cursor
const nextYear = new Date(Date.UTC(cursor.getUTCFullYear() + 1, 0, 1))
const left = dateToLeft(segStart)
const width = Math.max(Math.min(dateToLeft(nextYear), rightBound) - left, 2)
markers.push({ left, width, label: String(cursor.getUTCFullYear()) })
cursor.setUTCFullYear(cursor.getUTCFullYear() + 1)
}
return markers
}
return computeMonthSegments({ month: 'long', year: 'numeric', timeZone: 'UTC' })
})
const weekendColumns = computed(() => {
if (!windowStart.value || !windowEnd.value) return []
const columns: { left: number }[] = []
const cursor = new Date(windowStart.value)
while (cursor <= windowEnd.value) {
const day = cursor.getUTCDay()
if (day === 0 || day === 6) {
columns.push({ left: dateToLeft(cursor) })
}
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return columns
})
const todayLeft = computed(() => {
if (!windowStart.value || !windowEnd.value) return null
const now = new Date()
const todayUtc = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
if (todayUtc < windowStart.value || todayUtc > windowEnd.value) return null
return dateToLeft(todayUtc)
})
interface TaskTypeGroup {
taskType: string
tasks: TaskListItem[]
barLeft: number | null
barWidth: number | null
}
const taskTypeGroups = computed<TaskTypeGroup[]>(() => {
const byType = 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)
}
return Array.from(byType.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([taskType, groupTasks]) => {
const sorted = [...groupTasks].sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
let barLeft: number | null = null
let barWidth: number | null = null
if (windowStart.value) {
let minStart: Date | null = null
let maxEnd: Date | null = null
for (const t of sorted) {
const s = parseDate(t.start_date!)
const e = parseDate(t.deadline!)
if (!minStart || s < minStart) minStart = s
if (!maxEnd || e > maxEnd) maxEnd = e
}
barLeft = dateToLeft(minStart!)
barWidth = Math.max(dateToLeft(maxEnd!) - barLeft, 4)
}
return { taskType, tasks: sorted, barLeft, barWidth }
})
})
function isCollapsed(taskType: string): boolean {
return collapsedGroups.value.has(taskType)
}
function toggleGroup(taskType: string) {
const next = new Set(collapsedGroups.value)
if (next.has(taskType)) next.delete(taskType)
else next.add(taskType)
collapsedGroups.value = next
}
const hasCollapsedGroups = computed(() => collapsedGroups.value.size > 0)
function toggleAllGroups() {
collapsedGroups.value = hasCollapsedGroups.value
? new Set()
: new Set(taskTypeGroups.value.map(g => g.taskType))
}
function statusColor(task: TaskListItem): string {
const status = taskStatusesStore.getStatusById(props.projectId, task.status)
return status?.color || '#94A3B8'
}
function isTaskActive(task: TaskListItem): boolean {
return selectedTask.value?.id === task.id
}
// --- Drag to reschedule ---
interface DragState {
taskId: number
mode: 'move' | 'resize-start' | 'resize-end'
startX: number
originalStart: Date
originalEnd: Date
currentStart: Date
currentEnd: Date
}
const dragState = ref<DragState | null>(null)
function startDrag(event: MouseEvent, task: TaskListItem, mode: DragState['mode']) {
event.preventDefault()
event.stopPropagation()
const originalStart = parseDate(task.start_date!)
const originalEnd = parseDate(task.deadline!)
dragState.value = {
taskId: task.id,
mode,
startX: event.clientX,
originalStart,
originalEnd,
currentStart: originalStart,
currentEnd: originalEnd
}
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragMove(event: MouseEvent) {
const drag = dragState.value
if (!drag) return
const deltaX = event.clientX - drag.startX
const deltaDays = Math.round(deltaX / pixelsPerDay.value)
let newStart = drag.originalStart
let newEnd = drag.originalEnd
if (drag.mode === 'move') {
newStart = addDays(drag.originalStart, deltaDays)
newEnd = addDays(drag.originalEnd, deltaDays)
} else if (drag.mode === 'resize-start') {
newStart = addDays(drag.originalStart, deltaDays)
if (newStart >= drag.originalEnd) newStart = addDays(drag.originalEnd, -1)
} else if (drag.mode === 'resize-end') {
newEnd = addDays(drag.originalEnd, deltaDays)
if (newEnd <= drag.originalStart) newEnd = addDays(drag.originalStart, 1)
}
dragState.value = { ...drag, currentStart: newStart, currentEnd: newEnd }
}
async function handleDragEnd() {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
const drag = dragState.value
dragState.value = null
if (!drag) return
const changed = daysBetween(drag.originalStart, drag.currentStart) !== 0 || daysBetween(drag.originalEnd, drag.currentEnd) !== 0
if (!changed) {
// No movement - treat as a click on the bar body
if (drag.mode === 'move') openTask(drag.taskId)
return
}
const task = tasks.value.find(t => t.id === drag.taskId)
if (!task) return
const previousStart = task.start_date
const previousDeadline = task.deadline
const newStartStr = toDateString(drag.currentStart)
const newEndStr = toDateString(drag.currentEnd)
task.start_date = newStartStr
task.deadline = newEndStr
try {
await taskService.updateTask(drag.taskId, { start_date: newStartStr, deadline: newEndStr })
} catch (err: any) {
console.error('Failed to reschedule task:', err)
task.start_date = previousStart
task.deadline = previousDeadline
toast({
title: 'Error',
description: err.response?.data?.detail || 'Failed to reschedule task',
variant: 'destructive'
})
}
}
function taskBarStyle(task: TaskListItem) {
const isDragging = dragState.value?.taskId === task.id
const start = isDragging ? dragState.value!.currentStart : parseDate(task.start_date!)
const end = isDragging ? dragState.value!.currentEnd : parseDate(task.deadline!)
const left = dateToLeft(start)
const width = Math.max(dateToLeft(end) - left, 6)
return {
left: `${left}px`,
width: `${width}px`,
backgroundColor: statusColor(task)
}
}
function taskBarTitle(task: TaskListItem): string {
return `${task.name}: ${formatDate(task.start_date)} ${formatDate(task.deadline)}`
}
const legendStatuses = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId) || [])
function openTask(taskId: number) {
const task = tasks.value.find(t => t.id === taskId)
if (task) selectTask(task)
}
onMounted(() => {
loadTasks()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
})
watch(() => props.projectId, () => {
episodeFilter.value = null
collapsedGroups.value = new Set()
manualRangeStart.value = ''
manualRangeEnd.value = ''
loadTasks()
taskStatusesStore.fetchProjectStatuses(props.projectId)
})
</script>
@@ -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()
+6
View File
@@ -79,6 +79,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/project/ProjectTasksView.vue'),
meta: { tab: 'tasks', tabLabel: 'Tasks' }
},
{
path: 'schedule',
name: 'ProjectSchedule',
component: () => import('@/views/project/ProjectScheduleView.vue'),
meta: { tab: 'schedule', tabLabel: 'Schedule' }
},
{
path: 'settings',
name: 'ProjectSettings',
+5 -1
View File
@@ -11,6 +11,7 @@ export interface Task {
description?: string
task_type: string
status: TaskStatus
start_date?: string
deadline?: string
project_id: number
project_name?: string
@@ -33,6 +34,7 @@ export interface TaskListItem {
name: string
task_type: string
status: TaskStatus
start_date?: string
deadline?: string
project_id: number
project_name: string
@@ -115,6 +117,7 @@ export interface TaskFilters {
status?: string
taskType?: string
departmentRole?: string
limit?: number
}
export interface BulkStatusUpdateRequest {
@@ -143,7 +146,8 @@ class TaskService {
if (filters?.status) params.append('status', filters.status)
if (filters?.taskType) params.append('task_type', filters.taskType)
if (filters?.departmentRole) params.append('department_role', filters.departmentRole)
if (filters?.limit) params.append('limit', filters.limit.toString())
const response = await apiClient.get(`/tasks/?${params}`)
return response.data
}
@@ -0,0 +1,50 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="px-4 sm:px-6 py-4 sm:py-6 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Schedule</h2>
<p class="text-sm text-muted-foreground mt-1">
Production schedule for project planning
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
<ScheduleGantt :project-id="projectId" />
</div>
<div v-else class="p-4 sm:p-6">
<!-- No project selected -->
<Card>
<CardContent class="p-12 text-center">
<GanttChartSquare class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-semibold mb-2">No Project Selected</h3>
<p class="text-muted-foreground mb-4">
Please select a project to view its schedule.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { GanttChartSquare } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import ScheduleGantt from '@/components/schedule/ScheduleGantt.vue'
const route = useRoute()
const projectId = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? parseInt(id) : null
})
</script>