Add two-level tab structure (Schedule/Baseline/Workday Exceptions), calendar view, enhanced list view with progress rings and initials avatars, split-pane gantt view, workday exception management with business day integration, and baseline snapshot comparison.
403 lines
11 KiB
TypeScript
Executable File
403 lines
11 KiB
TypeScript
Executable File
"use server"
|
|
|
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
|
import { getDb } from "@/db"
|
|
import {
|
|
scheduleTasks,
|
|
taskDependencies,
|
|
workdayExceptions,
|
|
projects,
|
|
} from "@/db/schema"
|
|
import { eq, asc } from "drizzle-orm"
|
|
import { revalidatePath } from "next/cache"
|
|
import { calculateEndDate } from "@/lib/schedule/business-days"
|
|
import { findCriticalPath } from "@/lib/schedule/critical-path"
|
|
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
|
|
import { propagateDates } from "@/lib/schedule/propagate-dates"
|
|
import type {
|
|
TaskStatus,
|
|
DependencyType,
|
|
ExceptionCategory,
|
|
ExceptionRecurrence,
|
|
ScheduleData,
|
|
WorkdayExceptionData,
|
|
} from "@/lib/schedule/types"
|
|
|
|
async function fetchExceptions(
|
|
db: ReturnType<typeof getDb>,
|
|
projectId: string
|
|
): Promise<WorkdayExceptionData[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(workdayExceptions)
|
|
.where(eq(workdayExceptions.projectId, projectId))
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
category: r.category as ExceptionCategory,
|
|
recurrence: r.recurrence as ExceptionRecurrence,
|
|
}))
|
|
}
|
|
|
|
export async function getSchedule(
|
|
projectId: string
|
|
): Promise<ScheduleData> {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
const tasks = await db
|
|
.select()
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.projectId, projectId))
|
|
.orderBy(asc(scheduleTasks.sortOrder))
|
|
|
|
const deps = await db.select().from(taskDependencies)
|
|
const exceptions = await fetchExceptions(db, projectId)
|
|
|
|
const taskIds = new Set(tasks.map((t) => t.id))
|
|
const projectDeps = deps.filter(
|
|
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
|
|
)
|
|
|
|
return {
|
|
tasks: tasks.map((t) => ({
|
|
...t,
|
|
status: t.status as TaskStatus,
|
|
phase: t.phase,
|
|
})),
|
|
dependencies: projectDeps.map((d) => ({
|
|
...d,
|
|
type: d.type as DependencyType,
|
|
})),
|
|
exceptions,
|
|
}
|
|
}
|
|
|
|
export async function createTask(
|
|
projectId: string,
|
|
data: {
|
|
title: string
|
|
startDate: string
|
|
workdays: number
|
|
phase: string
|
|
isMilestone?: boolean
|
|
percentComplete?: number
|
|
assignedTo?: string
|
|
}
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
const exceptions = await fetchExceptions(db, projectId)
|
|
const endDate = calculateEndDate(
|
|
data.startDate, data.workdays, exceptions
|
|
)
|
|
const now = new Date().toISOString()
|
|
|
|
const existing = await db
|
|
.select({ sortOrder: scheduleTasks.sortOrder })
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.projectId, projectId))
|
|
.orderBy(asc(scheduleTasks.sortOrder))
|
|
|
|
const nextOrder = existing.length > 0
|
|
? existing[existing.length - 1].sortOrder + 1
|
|
: 0
|
|
|
|
const id = crypto.randomUUID()
|
|
await db.insert(scheduleTasks).values({
|
|
id,
|
|
projectId,
|
|
title: data.title,
|
|
startDate: data.startDate,
|
|
workdays: data.workdays,
|
|
endDateCalculated: endDate,
|
|
phase: data.phase,
|
|
status: "PENDING",
|
|
isCriticalPath: false,
|
|
isMilestone: data.isMilestone ?? false,
|
|
percentComplete: data.percentComplete ?? 0,
|
|
assignedTo: data.assignedTo ?? null,
|
|
sortOrder: nextOrder,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
|
|
await recalcCriticalPath(db, projectId)
|
|
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to create task:", error)
|
|
return { success: false, error: "Failed to create task" }
|
|
}
|
|
}
|
|
|
|
export async function updateTask(
|
|
taskId: string,
|
|
data: {
|
|
title?: string
|
|
startDate?: string
|
|
workdays?: number
|
|
phase?: string
|
|
isMilestone?: boolean
|
|
percentComplete?: number
|
|
assignedTo?: string | null
|
|
}
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
const [task] = await db
|
|
.select()
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.id, taskId))
|
|
.limit(1)
|
|
|
|
if (!task) return { success: false, error: "Task not found" }
|
|
|
|
const exceptions = await fetchExceptions(db, task.projectId)
|
|
const startDate = data.startDate ?? task.startDate
|
|
const workdays = data.workdays ?? task.workdays
|
|
const endDate = calculateEndDate(startDate, workdays, exceptions)
|
|
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({
|
|
...(data.title && { title: data.title }),
|
|
startDate,
|
|
workdays,
|
|
endDateCalculated: endDate,
|
|
...(data.phase && { phase: data.phase }),
|
|
...(data.isMilestone !== undefined && {
|
|
isMilestone: data.isMilestone,
|
|
}),
|
|
...(data.percentComplete !== undefined && {
|
|
percentComplete: data.percentComplete,
|
|
}),
|
|
...(data.assignedTo !== undefined && {
|
|
assignedTo: data.assignedTo,
|
|
}),
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
.where(eq(scheduleTasks.id, taskId))
|
|
|
|
// propagate date changes to downstream tasks
|
|
const schedule = await getSchedule(task.projectId)
|
|
const updatedTask = {
|
|
...task,
|
|
status: task.status as TaskStatus,
|
|
startDate,
|
|
workdays,
|
|
endDateCalculated: endDate,
|
|
}
|
|
const allTasks = schedule.tasks.map((t) =>
|
|
t.id === taskId ? updatedTask : t
|
|
)
|
|
const { updatedTasks } = propagateDates(
|
|
taskId, allTasks, schedule.dependencies, exceptions
|
|
)
|
|
|
|
for (const [id, dates] of updatedTasks) {
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({
|
|
startDate: dates.startDate,
|
|
endDateCalculated: dates.endDateCalculated,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
.where(eq(scheduleTasks.id, id))
|
|
}
|
|
|
|
await recalcCriticalPath(db, task.projectId)
|
|
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to update task:", error)
|
|
return { success: false, error: "Failed to update task" }
|
|
}
|
|
}
|
|
|
|
export async function deleteTask(
|
|
taskId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
const [task] = await db
|
|
.select()
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.id, taskId))
|
|
.limit(1)
|
|
|
|
if (!task) return { success: false, error: "Task not found" }
|
|
|
|
await db.delete(scheduleTasks).where(eq(scheduleTasks.id, taskId))
|
|
await recalcCriticalPath(db, task.projectId)
|
|
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to delete task:", error)
|
|
return { success: false, error: "Failed to delete task" }
|
|
}
|
|
}
|
|
|
|
export async function reorderTasks(
|
|
projectId: string,
|
|
items: { id: string; sortOrder: number }[]
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
for (const item of items) {
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({ sortOrder: item.sortOrder })
|
|
.where(eq(scheduleTasks.id, item.id))
|
|
}
|
|
|
|
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to reorder tasks:", error)
|
|
return { success: false, error: "Failed to reorder tasks" }
|
|
}
|
|
}
|
|
|
|
export async function createDependency(data: {
|
|
predecessorId: string
|
|
successorId: string
|
|
type: DependencyType
|
|
lagDays: number
|
|
projectId: string
|
|
}): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
// get existing deps for cycle check
|
|
const schedule = await getSchedule(data.projectId)
|
|
|
|
if (wouldCreateCycle(schedule.dependencies, data.predecessorId, data.successorId)) {
|
|
return { success: false, error: "This dependency would create a cycle" }
|
|
}
|
|
|
|
await db.insert(taskDependencies).values({
|
|
id: crypto.randomUUID(),
|
|
predecessorId: data.predecessorId,
|
|
successorId: data.successorId,
|
|
type: data.type,
|
|
lagDays: data.lagDays,
|
|
})
|
|
|
|
// propagate dates from predecessor
|
|
const updatedSchedule = await getSchedule(data.projectId)
|
|
const { updatedTasks } = propagateDates(
|
|
data.predecessorId,
|
|
updatedSchedule.tasks,
|
|
updatedSchedule.dependencies,
|
|
updatedSchedule.exceptions
|
|
)
|
|
|
|
for (const [id, dates] of updatedTasks) {
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({
|
|
startDate: dates.startDate,
|
|
endDateCalculated: dates.endDateCalculated,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
.where(eq(scheduleTasks.id, id))
|
|
}
|
|
|
|
await recalcCriticalPath(db, data.projectId)
|
|
revalidatePath(`/dashboard/projects/${data.projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to create dependency:", error)
|
|
return { success: false, error: "Failed to create dependency" }
|
|
}
|
|
}
|
|
|
|
export async function deleteDependency(
|
|
depId: string,
|
|
projectId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
await db.delete(taskDependencies).where(eq(taskDependencies.id, depId))
|
|
await recalcCriticalPath(db, projectId)
|
|
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to delete dependency:", error)
|
|
return { success: false, error: "Failed to delete dependency" }
|
|
}
|
|
}
|
|
|
|
export async function updateTaskStatus(
|
|
taskId: string,
|
|
status: TaskStatus
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
const db = getDb(env.DB)
|
|
|
|
const [task] = await db
|
|
.select()
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.id, taskId))
|
|
.limit(1)
|
|
|
|
if (!task) return { success: false, error: "Task not found" }
|
|
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({ status, updatedAt: new Date().toISOString() })
|
|
.where(eq(scheduleTasks.id, taskId))
|
|
|
|
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Failed to update task status:", error)
|
|
return { success: false, error: "Failed to update status" }
|
|
}
|
|
}
|
|
|
|
// recalculates critical path and updates all tasks
|
|
async function recalcCriticalPath(
|
|
db: ReturnType<typeof getDb>,
|
|
projectId: string
|
|
) {
|
|
const tasks = await db
|
|
.select()
|
|
.from(scheduleTasks)
|
|
.where(eq(scheduleTasks.projectId, projectId))
|
|
|
|
const deps = await db.select().from(taskDependencies)
|
|
const taskIds = new Set(tasks.map((t) => t.id))
|
|
const projectDeps = deps.filter(
|
|
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
|
|
)
|
|
|
|
const criticalSet = findCriticalPath(
|
|
tasks.map((t) => ({ ...t, status: t.status as TaskStatus })),
|
|
projectDeps.map((d) => ({ ...d, type: d.type as DependencyType }))
|
|
)
|
|
|
|
for (const task of tasks) {
|
|
const isCritical = criticalSet.has(task.id)
|
|
if (task.isCriticalPath !== isCritical) {
|
|
await db
|
|
.update(scheduleTasks)
|
|
.set({ isCriticalPath: isCritical })
|
|
.where(eq(scheduleTasks.id, task.id))
|
|
}
|
|
}
|
|
}
|