feat(schedule): add BuilderTrend parity enhancements

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.
This commit is contained in:
Nicholai Vogel 2026-01-23 20:14:09 -07:00
parent 67fed00bbd
commit aa6230c9d4
21 changed files with 2576 additions and 344 deletions

View File

@ -0,0 +1,26 @@
CREATE TABLE `schedule_baselines` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`snapshot_data` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `workday_exceptions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`title` text NOT NULL,
`start_date` text NOT NULL,
`end_date` text NOT NULL,
`type` text DEFAULT 'non_working' NOT NULL,
`category` text DEFAULT 'company_holiday' NOT NULL,
`recurrence` text DEFAULT 'one_time' NOT NULL,
`notes` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `schedule_tasks` ADD `percent_complete` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `schedule_tasks` ADD `assigned_to` text;

420
drizzle/meta/0001_snapshot.json Executable file
View File

@ -0,0 +1,420 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b55a2897-53cb-4da9-bfdb-c05ee8b6c08c",
"prevId": "198c9647-7cc5-484b-894e-824769bb5c79",
"tables": {
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schedule_baselines": {
"name": "schedule_baselines",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"snapshot_data": {
"name": "snapshot_data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"schedule_baselines_project_id_projects_id_fk": {
"name": "schedule_baselines_project_id_projects_id_fk",
"tableFrom": "schedule_baselines",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schedule_tasks": {
"name": "schedule_tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_date": {
"name": "start_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"workdays": {
"name": "workdays",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date_calculated": {
"name": "end_date_calculated",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phase": {
"name": "phase",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'PENDING'"
},
"is_critical_path": {
"name": "is_critical_path",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_milestone": {
"name": "is_milestone",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"percent_complete": {
"name": "percent_complete",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"assigned_to": {
"name": "assigned_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"schedule_tasks_project_id_projects_id_fk": {
"name": "schedule_tasks_project_id_projects_id_fk",
"tableFrom": "schedule_tasks",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_dependencies": {
"name": "task_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"predecessor_id": {
"name": "predecessor_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"successor_id": {
"name": "successor_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'FS'"
},
"lag_days": {
"name": "lag_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"task_dependencies_predecessor_id_schedule_tasks_id_fk": {
"name": "task_dependencies_predecessor_id_schedule_tasks_id_fk",
"tableFrom": "task_dependencies",
"tableTo": "schedule_tasks",
"columnsFrom": [
"predecessor_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_dependencies_successor_id_schedule_tasks_id_fk": {
"name": "task_dependencies_successor_id_schedule_tasks_id_fk",
"tableFrom": "task_dependencies",
"tableTo": "schedule_tasks",
"columnsFrom": [
"successor_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workday_exceptions": {
"name": "workday_exceptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_date": {
"name": "start_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'non_working'"
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'company_holiday'"
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'one_time'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"workday_exceptions_project_id_projects_id_fk": {
"name": "workday_exceptions_project_id_projects_id_fk",
"tableFrom": "workday_exceptions",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1769219785552, "when": 1769219785552,
"tag": "0000_nice_sabra", "tag": "0000_nice_sabra",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769222679525,
"tag": "0001_gorgeous_sebastian_shaw",
"breakpoints": true
} }
] ]
} }

91
src/app/actions/baselines.ts Executable file
View File

@ -0,0 +1,91 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import {
scheduleBaselines,
scheduleTasks,
taskDependencies,
} from "@/db/schema"
import { eq, asc } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import type { ScheduleBaselineData } from "@/lib/schedule/types"
export async function getBaselines(
projectId: string
): Promise<ScheduleBaselineData[]> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
return await db
.select()
.from(scheduleBaselines)
.where(eq(scheduleBaselines.projectId, projectId))
}
export async function createBaseline(
projectId: string,
name: string
): Promise<{ success: boolean; error?: string }> {
try {
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 taskIds = new Set(tasks.map((t) => t.id))
const projectDeps = deps.filter(
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
)
const snapshot = JSON.stringify({ tasks, dependencies: projectDeps })
await db.insert(scheduleBaselines).values({
id: crypto.randomUUID(),
projectId,
name,
snapshotData: snapshot,
createdAt: new Date().toISOString(),
})
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to create baseline:", error)
return { success: false, error: "Failed to create baseline" }
}
}
export async function deleteBaseline(
baselineId: string
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [existing] = await db
.select()
.from(scheduleBaselines)
.where(eq(scheduleBaselines.id, baselineId))
.limit(1)
if (!existing) return { success: false, error: "Baseline not found" }
await db
.delete(scheduleBaselines)
.where(eq(scheduleBaselines.id, baselineId))
revalidatePath(
`/dashboard/projects/${existing.projectId}/schedule`
)
return { success: true }
} catch (error) {
console.error("Failed to delete baseline:", error)
return { success: false, error: "Failed to delete baseline" }
}
}

View File

@ -2,7 +2,12 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { scheduleTasks, taskDependencies, projects } from "@/db/schema" import {
scheduleTasks,
taskDependencies,
workdayExceptions,
projects,
} from "@/db/schema"
import { eq, asc } from "drizzle-orm" import { eq, asc } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { calculateEndDate } from "@/lib/schedule/business-days" import { calculateEndDate } from "@/lib/schedule/business-days"
@ -12,9 +17,28 @@ import { propagateDates } from "@/lib/schedule/propagate-dates"
import type { import type {
TaskStatus, TaskStatus,
DependencyType, DependencyType,
ExceptionCategory,
ExceptionRecurrence,
ScheduleData, ScheduleData,
WorkdayExceptionData,
} from "@/lib/schedule/types" } 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( export async function getSchedule(
projectId: string projectId: string
): Promise<ScheduleData> { ): Promise<ScheduleData> {
@ -28,8 +52,8 @@ export async function getSchedule(
.orderBy(asc(scheduleTasks.sortOrder)) .orderBy(asc(scheduleTasks.sortOrder))
const deps = await db.select().from(taskDependencies) const deps = await db.select().from(taskDependencies)
const exceptions = await fetchExceptions(db, projectId)
// filter deps to only include tasks in this project
const taskIds = new Set(tasks.map((t) => t.id)) const taskIds = new Set(tasks.map((t) => t.id))
const projectDeps = deps.filter( const projectDeps = deps.filter(
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId) (d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
@ -45,6 +69,7 @@ export async function getSchedule(
...d, ...d,
type: d.type as DependencyType, type: d.type as DependencyType,
})), })),
exceptions,
} }
} }
@ -56,16 +81,20 @@ export async function createTask(
workdays: number workdays: number
phase: string phase: string
isMilestone?: boolean isMilestone?: boolean
percentComplete?: number
assignedTo?: string
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const endDate = calculateEndDate(data.startDate, data.workdays) const exceptions = await fetchExceptions(db, projectId)
const endDate = calculateEndDate(
data.startDate, data.workdays, exceptions
)
const now = new Date().toISOString() const now = new Date().toISOString()
// get next sort order
const existing = await db const existing = await db
.select({ sortOrder: scheduleTasks.sortOrder }) .select({ sortOrder: scheduleTasks.sortOrder })
.from(scheduleTasks) .from(scheduleTasks)
@ -88,6 +117,8 @@ export async function createTask(
status: "PENDING", status: "PENDING",
isCriticalPath: false, isCriticalPath: false,
isMilestone: data.isMilestone ?? false, isMilestone: data.isMilestone ?? false,
percentComplete: data.percentComplete ?? 0,
assignedTo: data.assignedTo ?? null,
sortOrder: nextOrder, sortOrder: nextOrder,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -110,6 +141,8 @@ export async function updateTask(
workdays?: number workdays?: number
phase?: string phase?: string
isMilestone?: boolean isMilestone?: boolean
percentComplete?: number
assignedTo?: string | null
} }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
@ -124,9 +157,10 @@ export async function updateTask(
if (!task) return { success: false, error: "Task not found" } if (!task) return { success: false, error: "Task not found" }
const exceptions = await fetchExceptions(db, task.projectId)
const startDate = data.startDate ?? task.startDate const startDate = data.startDate ?? task.startDate
const workdays = data.workdays ?? task.workdays const workdays = data.workdays ?? task.workdays
const endDate = calculateEndDate(startDate, workdays) const endDate = calculateEndDate(startDate, workdays, exceptions)
await db await db
.update(scheduleTasks) .update(scheduleTasks)
@ -136,18 +170,34 @@ export async function updateTask(
workdays, workdays,
endDateCalculated: endDate, endDateCalculated: endDate,
...(data.phase && { phase: data.phase }), ...(data.phase && { phase: data.phase }),
...(data.isMilestone !== undefined && { isMilestone: data.isMilestone }), ...(data.isMilestone !== undefined && {
isMilestone: data.isMilestone,
}),
...(data.percentComplete !== undefined && {
percentComplete: data.percentComplete,
}),
...(data.assignedTo !== undefined && {
assignedTo: data.assignedTo,
}),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
.where(eq(scheduleTasks.id, taskId)) .where(eq(scheduleTasks.id, taskId))
// propagate date changes to downstream tasks // propagate date changes to downstream tasks
const schedule = await getSchedule(task.projectId) const schedule = await getSchedule(task.projectId)
const updatedTask = { ...task, startDate, workdays, endDateCalculated: endDate } const updatedTask = {
...task,
status: task.status as TaskStatus,
startDate,
workdays,
endDateCalculated: endDate,
}
const allTasks = schedule.tasks.map((t) => const allTasks = schedule.tasks.map((t) =>
t.id === taskId ? updatedTask : t t.id === taskId ? updatedTask : t
) )
const { updatedTasks } = propagateDates(taskId, allTasks, schedule.dependencies) const { updatedTasks } = propagateDates(
taskId, allTasks, schedule.dependencies, exceptions
)
for (const [id, dates] of updatedTasks) { for (const [id, dates] of updatedTasks) {
await db await db
@ -248,7 +298,8 @@ export async function createDependency(data: {
const { updatedTasks } = propagateDates( const { updatedTasks } = propagateDates(
data.predecessorId, data.predecessorId,
updatedSchedule.tasks, updatedSchedule.tasks,
updatedSchedule.dependencies updatedSchedule.dependencies,
updatedSchedule.exceptions
) )
for (const [id, dates] of updatedTasks) { for (const [id, dates] of updatedTasks) {

View File

@ -0,0 +1,146 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { workdayExceptions } from "@/db/schema"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import type {
WorkdayExceptionData,
ExceptionCategory,
ExceptionRecurrence,
} from "@/lib/schedule/types"
export async function getWorkdayExceptions(
projectId: string
): Promise<WorkdayExceptionData[]> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
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 createWorkdayException(
projectId: string,
data: {
title: string
startDate: string
endDate: string
type: string
category: ExceptionCategory
recurrence: ExceptionRecurrence
notes?: string
}
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
await db.insert(workdayExceptions).values({
id: crypto.randomUUID(),
projectId,
title: data.title,
startDate: data.startDate,
endDate: data.endDate,
type: data.type,
category: data.category,
recurrence: data.recurrence,
notes: data.notes ?? null,
createdAt: now,
updatedAt: now,
})
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return { success: true }
} catch (error) {
console.error("Failed to create workday exception:", error)
return { success: false, error: "Failed to create exception" }
}
}
export async function updateWorkdayException(
exceptionId: string,
data: {
title?: string
startDate?: string
endDate?: string
type?: string
category?: ExceptionCategory
recurrence?: ExceptionRecurrence
notes?: string | null
}
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [existing] = await db
.select()
.from(workdayExceptions)
.where(eq(workdayExceptions.id, exceptionId))
.limit(1)
if (!existing) return { success: false, error: "Exception not found" }
await db
.update(workdayExceptions)
.set({
...(data.title && { title: data.title }),
...(data.startDate && { startDate: data.startDate }),
...(data.endDate && { endDate: data.endDate }),
...(data.type && { type: data.type }),
...(data.category && { category: data.category }),
...(data.recurrence && { recurrence: data.recurrence }),
...(data.notes !== undefined && { notes: data.notes }),
updatedAt: new Date().toISOString(),
})
.where(eq(workdayExceptions.id, exceptionId))
revalidatePath(
`/dashboard/projects/${existing.projectId}/schedule`
)
return { success: true }
} catch (error) {
console.error("Failed to update workday exception:", error)
return { success: false, error: "Failed to update exception" }
}
}
export async function deleteWorkdayException(
exceptionId: string
): Promise<{ success: boolean; error?: string }> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [existing] = await db
.select()
.from(workdayExceptions)
.where(eq(workdayExceptions.id, exceptionId))
.limit(1)
if (!existing) return { success: false, error: "Exception not found" }
await db
.delete(workdayExceptions)
.where(eq(workdayExceptions.id, exceptionId))
revalidatePath(
`/dashboard/projects/${existing.projectId}/schedule`
)
return { success: true }
} catch (error) {
console.error("Failed to delete workday exception:", error)
return { success: false, error: "Failed to delete exception" }
}
}

View File

@ -4,7 +4,15 @@ import { projects } from "@/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getSchedule } from "@/app/actions/schedule" import { getSchedule } from "@/app/actions/schedule"
import { getBaselines } from "@/app/actions/baselines"
import { ScheduleView } from "@/components/schedule/schedule-view" import { ScheduleView } from "@/components/schedule/schedule-view"
import type { ScheduleData, ScheduleBaselineData } from "@/lib/schedule/types"
const emptySchedule: ScheduleData = {
tasks: [],
dependencies: [],
exceptions: [],
}
export default async function SchedulePage({ export default async function SchedulePage({
params, params,
@ -12,9 +20,16 @@ export default async function SchedulePage({
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
let projectName = "Project"
let schedule: ScheduleData = emptySchedule
let baselines: ScheduleBaselineData[] = []
try {
const { env } = await getCloudflareContext()
if (!env?.DB) throw new Error("D1 not available")
const db = getDb(env.DB)
const [project] = await db const [project] = await db
.select() .select()
.from(projects) .from(projects)
@ -23,14 +38,23 @@ export default async function SchedulePage({
if (!project) notFound() if (!project) notFound()
const schedule = await getSchedule(id) projectName = project.name
;[schedule, baselines] = await Promise.all([
getSchedule(id),
getBaselines(id),
])
} catch (e: any) {
if (e?.digest === "NEXT_NOT_FOUND") throw e
console.warn("D1 unavailable in dev mode, using empty data")
}
return ( return (
<div className="p-6"> <div className="p-6">
<ScheduleView <ScheduleView
projectId={id} projectId={id}
projectName={project.name} projectName={projectName}
initialData={schedule} initialData={schedule}
baselines={baselines}
/> />
</div> </div>
) )

View File

@ -0,0 +1,274 @@
"use client"
import { useState, useCallback, useMemo } from "react"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { IconTrash, IconGitCompare } from "@tabler/icons-react"
import { createBaseline, deleteBaseline } from "@/app/actions/baselines"
import type {
ScheduleBaselineData,
ScheduleTaskData,
} from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { format, parseISO } from "date-fns"
interface ScheduleBaselineViewProps {
projectId: string
baselines: ScheduleBaselineData[]
currentTasks: ScheduleTaskData[]
}
function formatDate(dateStr: string): string {
try {
return format(parseISO(dateStr), "MMM d, yyyy")
} catch {
return dateStr
}
}
interface SnapshotTask {
id: string
title: string
startDate: string
endDateCalculated: string
workdays: number
}
export function ScheduleBaselineView({
projectId,
baselines,
currentTasks,
}: ScheduleBaselineViewProps) {
const router = useRouter()
const [baselineName, setBaselineName] = useState("")
const [saving, setSaving] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const handleSave = useCallback(async () => {
if (!baselineName.trim()) {
toast.error("Baseline name is required")
return
}
setSaving(true)
const result = await createBaseline(projectId, baselineName.trim())
setSaving(false)
if (result.success) {
setBaselineName("")
router.refresh()
} else {
toast.error(result.error)
}
}, [projectId, baselineName, router])
const handleDelete = useCallback(
async (id: string) => {
const result = await deleteBaseline(id)
if (result.success) {
if (selectedId === id) setSelectedId(null)
router.refresh()
} else {
toast.error(result.error)
}
},
[router, selectedId]
)
const comparison = useMemo(() => {
if (!selectedId) return null
const baseline = baselines.find((b) => b.id === selectedId)
if (!baseline) return null
let snapshotTasks: SnapshotTask[] = []
try {
const parsed = JSON.parse(baseline.snapshotData)
snapshotTasks = parsed.tasks || []
} catch {
return null
}
const snapshotMap = new Map(
snapshotTasks.map((t) => [t.id, t])
)
return currentTasks.map((current) => {
const original = snapshotMap.get(current.id)
if (!original) {
return {
title: current.title,
originalStart: "-",
originalEnd: "-",
currentStart: current.startDate,
currentEnd: current.endDateCalculated,
variance: "New",
}
}
const origDays = original.workdays
const currDays = current.workdays
const diff = currDays - origDays
return {
title: current.title,
originalStart: original.startDate,
originalEnd: original.endDateCalculated,
currentStart: current.startDate,
currentEnd: current.endDateCalculated,
variance:
diff === 0
? "On track"
: diff > 0
? `+${diff} days`
: `${diff} days`,
}
})
}, [selectedId, baselines, currentTasks])
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Input
placeholder="Baseline name..."
value={baselineName}
onChange={(e) => setBaselineName(e.target.value)}
className="max-w-[250px]"
/>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !baselineName.trim()}
>
Save Baseline
</Button>
</div>
{baselines.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium mb-2">Saved Baselines</h3>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[100px]" />
</TableRow>
</TableHeader>
<TableBody>
{baselines.map((b) => (
<TableRow
key={b.id}
className={
selectedId === b.id ? "bg-muted/50" : ""
}
>
<TableCell className="font-medium text-sm">
{b.name}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(b.createdAt.split("T")[0])}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
setSelectedId(
selectedId === b.id ? null : b.id
)
}
>
<IconGitCompare className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => handleDelete(b.id)}
>
<IconTrash className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{comparison && (
<div>
<h3 className="text-sm font-medium mb-2">
Comparison: Current vs Baseline
</h3>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Baseline Start</TableHead>
<TableHead>Baseline End</TableHead>
<TableHead>Current Start</TableHead>
<TableHead>Current End</TableHead>
<TableHead>Variance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{comparison.map((row, i) => (
<TableRow key={i}>
<TableCell className="font-medium text-sm">
{row.title}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.originalStart === "-"
? "-"
: formatDate(row.originalStart)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.originalEnd === "-"
? "-"
: formatDate(row.originalEnd)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(row.currentStart)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(row.currentEnd)}
</TableCell>
<TableCell>
<span
className={`text-xs font-medium ${
row.variance === "On track"
? "text-green-600"
: row.variance === "New"
? "text-blue-600"
: row.variance.startsWith("+")
? "text-red-600"
: "text-green-600"
}`}
>
{row.variance}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,233 @@
"use client"
import { useState, useMemo } from "react"
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
addMonths,
subMonths,
isToday,
isSameMonth,
isWeekend,
isSameDay,
parseISO,
isWithinInterval,
} from "date-fns"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import type {
ScheduleTaskData,
WorkdayExceptionData,
} from "@/lib/schedule/types"
interface ScheduleCalendarViewProps {
projectId: string
tasks: ScheduleTaskData[]
exceptions: WorkdayExceptionData[]
}
function isExceptionDay(
date: Date,
exceptions: WorkdayExceptionData[]
): boolean {
return exceptions.some((ex) => {
const start = parseISO(ex.startDate)
const end = parseISO(ex.endDate)
return isWithinInterval(date, { start, end })
})
}
function getTaskColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "bg-green-500"
if (task.status === "IN_PROGRESS") return "bg-blue-500"
if (task.status === "BLOCKED") return "bg-red-500"
if (task.isCriticalPath) return "bg-orange-500"
return "bg-gray-400"
}
const MAX_VISIBLE_TASKS = 3
export function ScheduleCalendarView({
tasks,
exceptions,
}: ScheduleCalendarViewProps) {
const [currentDate, setCurrentDate] = useState(new Date())
const [expandedCells, setExpandedCells] = useState<Set<string>>(
new Set()
)
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
const days = useMemo(
() => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
[calendarStart.getTime(), calendarEnd.getTime()]
)
const tasksByDate = useMemo(() => {
const map = new Map<string, ScheduleTaskData[]>()
for (const task of tasks) {
const key = task.startDate
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(task)
}
return map
}, [tasks])
const toggleExpand = (dateKey: string) => {
setExpandedCells((prev) => {
const next = new Set(prev)
if (next.has(dateKey)) {
next.delete(dateKey)
} else {
next.add(dateKey)
}
return next
})
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
>
Today
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
<IconChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
<IconChevronRight className="size-4" />
</Button>
<h2 className="text-lg font-medium">
{format(currentDate, "MMMM, yyyy")}
</h2>
</div>
<Select defaultValue="month">
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="day">Day</SelectItem>
</SelectContent>
</Select>
</div>
<div className="border rounded-md overflow-hidden">
<div className="grid grid-cols-7 border-b">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(
(day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2 border-r last:border-r-0"
>
{day}
</div>
)
)}
</div>
<div className="grid grid-cols-7">
{days.map((day) => {
const dateKey = format(day, "yyyy-MM-dd")
const dayTasks = tasksByDate.get(dateKey) || []
const isNonWork =
isWeekend(day) || isExceptionDay(day, exceptions)
const inMonth = isSameMonth(day, currentDate)
const expanded = expandedCells.has(dateKey)
const visibleTasks = expanded
? dayTasks
: dayTasks.slice(0, MAX_VISIBLE_TASKS)
const overflow = dayTasks.length - MAX_VISIBLE_TASKS
return (
<div
key={dateKey}
className={`min-h-[90px] border-r border-b last:border-r-0 p-1 ${
!inMonth ? "bg-muted/30" : ""
} ${isNonWork ? "bg-muted/50" : ""}`}
>
<div className="flex items-center justify-between mb-0.5">
<span
className={`text-xs ${
isToday(day)
? "bg-primary text-primary-foreground rounded-full size-5 flex items-center justify-center font-bold"
: inMonth
? "text-foreground"
: "text-muted-foreground"
}`}
>
{format(day, "d")}
</span>
{isNonWork && (
<span className="text-[9px] text-muted-foreground">
Non-workday
</span>
)}
</div>
<div className="space-y-0.5">
{visibleTasks.map((task) => (
<div
key={task.id}
className={`${getTaskColor(task)} text-white text-[9px] px-1 py-0.5 rounded truncate`}
title={task.title}
>
{task.title}
</div>
))}
{!expanded && overflow > 0 && (
<button
className="text-[9px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
+{overflow} more
</button>
)}
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && (
<button
className="text-[9px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
Show less
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -1,9 +1,24 @@
"use client" "use client"
import { useState, useCallback } from "react" import { useState, useCallback } from "react"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { IconPencil, IconPlus } from "@tabler/icons-react"
import { GanttChart } from "./gantt-chart" import { GanttChart } from "./gantt-chart"
import "./gantt.css" import { TaskFormDialog } from "./task-form-dialog"
import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform" import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform"
import { updateTask } from "@/app/actions/schedule" import { updateTask } from "@/app/actions/schedule"
import { countBusinessDays } from "@/lib/schedule/business-days" import { countBusinessDays } from "@/lib/schedule/business-days"
@ -31,8 +46,18 @@ export function ScheduleGanttView({
}: ScheduleGanttViewProps) { }: ScheduleGanttViewProps) {
const router = useRouter() const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>("Week") const [viewMode, setViewMode] = useState<ViewMode>("Week")
const [showPhases, setShowPhases] = useState(false)
const [showCriticalPath, setShowCriticalPath] = useState(false)
const [taskFormOpen, setTaskFormOpen] = useState(false)
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
null
)
const frappeTasks = transformToFrappeTasks(tasks, dependencies) const filteredTasks = showCriticalPath
? tasks.filter((t) => t.isCriticalPath)
: tasks
const frappeTasks = transformToFrappeTasks(filteredTasks, dependencies)
const handleDateChange = useCallback( const handleDateChange = useCallback(
async (task: FrappeTask, start: Date, end: Date) => { async (task: FrappeTask, start: Date, end: Date) => {
@ -54,9 +79,17 @@ export function ScheduleGanttView({
[router] [router]
) )
const scrollToToday = () => {
const todayEl = document.querySelector(".gantt-container .today-highlight")
if (todayEl) {
todayEl.scrollIntoView({ behavior: "smooth", inline: "center" })
}
}
return ( return (
<div> <div>
<div className="flex gap-1 mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => ( {(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
<Button <Button
key={mode} key={mode}
@ -67,13 +100,132 @@ export function ScheduleGanttView({
{mode} {mode}
</Button> </Button>
))} ))}
<Button
variant="outline"
size="sm"
onClick={scrollToToday}
>
Today
</Button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Switch
checked={showPhases}
onCheckedChange={setShowPhases}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
Phases
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={showCriticalPath}
onCheckedChange={setShowCriticalPath}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
Critical Path
</span>
</div>
</div>
</div> </div>
<ResizablePanelGroup
orientation="horizontal"
className="border rounded-md min-h-[400px]"
>
<ResizablePanel defaultSize={30} minSize={20}>
<div className="h-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Title</TableHead>
<TableHead className="text-xs w-[80px]">
Start
</TableHead>
<TableHead className="text-xs w-[60px]">
Days
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
<span
className={
showPhases
? "border-l-2 pl-1.5 border-primary"
: ""
}
>
{task.title}
</span>
</TableCell>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{task.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5">
{task.workdays}
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditingTask(task)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3" />
</Button>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={4} className="py-1">
<Button
variant="ghost"
size="sm"
className="text-xs w-full justify-start"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-3 mr-1" />
Add Task
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={70} minSize={40}>
<div className="h-full overflow-auto p-2">
<GanttChart <GanttChart
tasks={frappeTasks} tasks={frappeTasks}
viewMode={viewMode} viewMode={viewMode}
onDateChange={handleDateChange} onDateChange={handleDateChange}
/> />
</div> </div>
</ResizablePanel>
</ResizablePanelGroup>
<TaskFormDialog
open={taskFormOpen}
onOpenChange={setTaskFormOpen}
projectId={projectId}
editingTask={editingTask}
/>
</div>
) )
} }

View File

@ -1,10 +1,10 @@
"use client" "use client"
import { useState, useCallback, useEffect } from "react" import { useState, useCallback, useEffect, useMemo } from "react"
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
getGroupedRowModel, getPaginationRowModel,
flexRender, flexRender,
type ColumnDef, type ColumnDef,
} from "@tanstack/react-table" } from "@tanstack/react-table"
@ -17,45 +17,12 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox"
import { import {
IconPlus, IconPencil,
IconLink,
IconGripVertical,
IconTrash, IconTrash,
IconLink,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { TaskFormDialog } from "./task-form-dialog"
import { DependencyDialog } from "./dependency-dialog"
import {
deleteTask,
updateTaskStatus,
reorderTasks,
} from "@/app/actions/schedule"
import { getPhaseColor } from "@/lib/schedule/phase-colors"
import type {
ScheduleTaskData,
TaskDependencyData,
TaskStatus,
} from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -63,6 +30,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { TaskFormDialog } from "./task-form-dialog"
import { DependencyDialog } from "./dependency-dialog"
import { deleteTask } from "@/app/actions/schedule"
import type {
ScheduleTaskData,
TaskDependencyData,
} from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { format, parseISO } from "date-fns"
interface ScheduleListViewProps { interface ScheduleListViewProps {
projectId: string projectId: string
@ -70,49 +47,87 @@ interface ScheduleListViewProps {
dependencies: TaskDependencyData[] dependencies: TaskDependencyData[]
} }
const statusOptions: { value: TaskStatus; label: string }[] = [ function StatusDot({ task }: { task: ScheduleTaskData }) {
{ value: "PENDING", label: "Pending" }, let color = "bg-gray-400"
{ value: "IN_PROGRESS", label: "In Progress" }, if (task.status === "COMPLETE") color = "bg-green-500"
{ value: "COMPLETE", label: "Complete" }, else if (task.status === "IN_PROGRESS") color = "bg-blue-500"
{ value: "BLOCKED", label: "Blocked" }, else if (task.status === "BLOCKED") color = "bg-red-500"
] else if (task.isCriticalPath) color = "bg-orange-500"
return <span className={`inline-block size-2.5 rounded-full ${color}`} />
function SortableRow({
row,
children,
}: {
row: { id: string }
children: React.ReactNode
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
} }
function ProgressRing({
percent,
size = 28,
}: {
percent: number
size?: number
}) {
const stroke = 3
const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius
const offset = circumference - (percent / 100) * circumference
return ( return (
<TableRow ref={setNodeRef} style={style}> <div className="relative inline-flex items-center justify-center">
<TableCell className="w-8"> <svg width={size} height={size} className="-rotate-90">
<IconGripVertical <circle
className="size-4 text-muted-foreground cursor-grab" cx={size / 2}
{...attributes} cy={size / 2}
{...listeners} r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-muted-foreground/20"
/> />
</TableCell> <circle
{children} cx={size / 2}
</TableRow> cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="text-primary"
/>
</svg>
<span className="absolute text-[9px] font-medium">
{percent}%
</span>
</div>
) )
} }
function InitialsAvatar({ name }: { name: string }) {
const initials = name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
return (
<div className="flex items-center gap-1.5">
<div className="size-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-medium">
{initials}
</div>
<span className="text-xs text-muted-foreground truncate max-w-[80px]">
{name}
</span>
</div>
)
}
function formatDate(dateStr: string): string {
try {
return format(parseISO(dateStr), "MMM d, yyyy")
} catch {
return dateStr
}
}
export function ScheduleListView({ export function ScheduleListView({
projectId, projectId,
tasks, tasks,
@ -123,30 +138,12 @@ export function ScheduleListView({
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(null) const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(null)
const [depDialogOpen, setDepDialogOpen] = useState(false) const [depDialogOpen, setDepDialogOpen] = useState(false)
const [localTasks, setLocalTasks] = useState(tasks) const [localTasks, setLocalTasks] = useState(tasks)
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
useEffect(() => { useEffect(() => {
setLocalTasks(tasks) setLocalTasks(tasks)
}, [tasks]) }, [tasks])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleStatusChange = useCallback(
async (taskId: string, status: TaskStatus) => {
const result = await updateTaskStatus(taskId, status)
if (result.success) {
router.refresh()
} else {
toast.error(result.error)
}
},
[router]
)
const handleDelete = useCallback( const handleDelete = useCallback(
async (taskId: string) => { async (taskId: string) => {
const result = await deleteTask(taskId) const result = await deleteTask(taskId)
@ -159,48 +156,81 @@ export function ScheduleListView({
[router] [router]
) )
const handleDragEnd = useCallback( const columns: ColumnDef<ScheduleTaskData>[] = useMemo(
async (event: DragEndEvent) => { () => [
const { active, over } = event {
if (!over || active.id === over.id) return id: "select",
header: ({ table }) => (
const oldIndex = localTasks.findIndex((t) => t.id === active.id) <Checkbox
const newIndex = localTasks.findIndex((t) => t.id === over.id) checked={table.getIsAllRowsSelected()}
const reordered = arrayMove(localTasks, oldIndex, newIndex) onCheckedChange={(value) =>
setLocalTasks(reordered) table.toggleAllRowsSelected(!!value)
}
const items = reordered.map((t, i) => ({ id: t.id, sortOrder: i })) />
await reorderTasks(projectId, items) ),
router.refresh() cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
size: 32,
},
{
id: "idNum",
header: "#",
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{row.original.sortOrder + 1}
</span>
),
size: 40,
}, },
[localTasks, projectId, router]
)
const columns: ColumnDef<ScheduleTaskData>[] = [
{ {
accessorKey: "title", accessorKey: "title",
header: "Task", header: "Title",
cell: ({ row }) => ( cell: ({ row }) => (
<button <div className="flex items-center gap-2">
className="text-left font-medium hover:underline" <StatusDot task={row.original} />
onClick={() => { <span className="font-medium text-sm truncate max-w-[200px]">
setEditingTask(row.original)
setTaskFormOpen(true)
}}
>
{row.original.title} {row.original.title}
{row.original.isMilestone && ( </span>
<span className="ml-2 text-xs text-muted-foreground"></span> </div>
)}
</button>
), ),
}, },
{
id: "complete",
header: "Complete",
cell: ({ row }) => (
<ProgressRing percent={row.original.percentComplete} />
),
size: 70,
},
{
accessorKey: "phase",
header: "Phase",
cell: ({ row }) => (
<span className="text-xs text-muted-foreground truncate max-w-[80px] inline-block">
{row.original.phase}
</span>
),
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => (
<span className="text-xs">
{row.original.workdays} {row.original.workdays === 1 ? "day" : "days"}
</span>
),
size: 80,
},
{ {
accessorKey: "startDate", accessorKey: "startDate",
header: "Start", header: "Start",
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <span className="text-xs text-muted-foreground">
{row.original.startDate} {formatDate(row.original.startDate)}
</span> </span>
), ),
}, },
@ -208,98 +238,66 @@ export function ScheduleListView({
accessorKey: "endDateCalculated", accessorKey: "endDateCalculated",
header: "End", header: "End",
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <span className="text-xs text-muted-foreground">
{row.original.endDateCalculated} {formatDate(row.original.endDateCalculated)}
</span> </span>
), ),
}, },
{ {
accessorKey: "workdays", id: "assignedTo",
header: "Days", header: "Assigned To",
cell: ({ row }) => (
<span className="text-sm">{row.original.workdays}</span>
),
},
{
accessorKey: "phase",
header: "Phase",
cell: ({ row }) => {
const colors = getPhaseColor(row.original.phase)
return (
<Badge variant="secondary" className={colors.badge}>
{row.original.phase}
</Badge>
)
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Select
value={row.original.status}
onValueChange={(val) =>
handleStatusChange(row.original.id, val as TaskStatus)
}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
id: "criticalPath",
header: "CP",
cell: ({ row }) => cell: ({ row }) =>
row.original.isCriticalPath ? ( row.original.assignedTo ? (
<Badge variant="destructive" className="text-xs"> <InitialsAvatar name={row.original.assignedTo} />
Critical ) : (
</Badge> <span className="text-xs text-muted-foreground">-</span>
) : null, ),
}, },
{ {
id: "actions", id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => {
setEditingTask(row.original)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3.5" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7" className="size-7"
onClick={() => handleDelete(row.original.id)} onClick={() => handleDelete(row.original.id)}
> >
<IconTrash className="size-4" /> <IconTrash className="size-3.5" />
</Button> </Button>
</div>
), ),
size: 80,
}, },
] ],
[handleDelete]
)
const table = useReactTable({ const table = useReactTable({
data: localTasks, data: localTasks,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id, getRowId: (row) => row.id,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
initialState: { pagination: { pageSize: 25 } },
}) })
return ( return (
<div> <div>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<Button
size="sm"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-4 mr-1" />
Add Task
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -312,16 +310,10 @@ export function ScheduleListView({
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
<TableHead className="w-8" />
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder
@ -336,22 +328,18 @@ export function ScheduleListView({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<SortableContext
items={localTasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.length === 0 ? ( {table.getRowModel().rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={columns.length + 1} colSpan={columns.length}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
No tasks yet. Click &quot;Add Task&quot; to get started. No tasks yet. Click &quot;New Schedule Item&quot; to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<SortableRow key={row.id} row={row}> <TableRow key={row.id}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
@ -360,13 +348,58 @@ export function ScheduleListView({
)} )}
</TableCell> </TableCell>
))} ))}
</SortableRow> </TableRow>
)) ))
)} )}
</SortableContext>
</TableBody> </TableBody>
</Table> </Table>
</DndContext> </div>
<div className="flex items-center justify-between mt-3 px-1">
<span className="text-xs text-muted-foreground">
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}
-
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
localTasks.length
)}{" "}
of {localTasks.length} items
</span>
<div className="flex items-center gap-2">
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(val) => table.setPageSize(Number(val))}
>
<SelectTrigger className="h-7 w-[70px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div> </div>
<TaskFormDialog <TaskFormDialog

View File

@ -0,0 +1,70 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
IconSettings,
IconHistory,
IconFilter,
IconPlus,
IconDots,
} from "@tabler/icons-react"
interface ScheduleToolbarProps {
onNewItem: () => void
}
export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
const [offlineMode, setOfflineMode] = useState(false)
return (
<div className="flex items-center justify-between py-3 border-b mb-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="size-8">
<IconSettings className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-8">
<IconHistory className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Switch
checked={offlineMode}
onCheckedChange={setOfflineMode}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
Schedule Offline
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<IconDots className="size-4 mr-1" />
More Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Export Schedule</DropdownMenuItem>
<DropdownMenuItem>Import Schedule</DropdownMenuItem>
<DropdownMenuItem>Print</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" className="text-xs">
<IconFilter className="size-4 mr-1" />
Filter
</Button>
</div>
<Button size="sm" onClick={onNewItem}>
<IconPlus className="size-4 mr-1" />
New Schedule Item
</Button>
</div>
)
}

View File

@ -2,35 +2,94 @@
import { useState } from "react" import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ScheduleToolbar } from "./schedule-toolbar"
import { ScheduleListView } from "./schedule-list-view" import { ScheduleListView } from "./schedule-list-view"
import { ScheduleGanttView } from "./schedule-gantt-view" import { ScheduleGanttView } from "./schedule-gantt-view"
import type { ScheduleData } from "@/lib/schedule/types" import { ScheduleCalendarView } from "./schedule-calendar-view"
import { WorkdayExceptionsView } from "./workday-exceptions-view"
import { ScheduleBaselineView } from "./schedule-baseline-view"
import { TaskFormDialog } from "./task-form-dialog"
import type {
ScheduleData,
ScheduleBaselineData,
} from "@/lib/schedule/types"
type TopTab = "schedule" | "baseline" | "exceptions"
type ScheduleSubTab = "calendar" | "list" | "gantt"
interface ScheduleViewProps { interface ScheduleViewProps {
projectId: string projectId: string
projectName: string projectName: string
initialData: ScheduleData initialData: ScheduleData
baselines: ScheduleBaselineData[]
} }
export function ScheduleView({ export function ScheduleView({
projectId, projectId,
projectName, projectName,
initialData, initialData,
baselines,
}: ScheduleViewProps) { }: ScheduleViewProps) {
const [activeTab, setActiveTab] = useState("list") const [topTab, setTopTab] = useState<TopTab>("schedule")
const [subTab, setSubTab] = useState<ScheduleSubTab>("list")
const [taskFormOpen, setTaskFormOpen] = useState(false)
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">{projectName} - Schedule</h1> <h1 className="text-2xl font-semibold">
{projectName} - Schedule
</h1>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs
value={topTab}
onValueChange={(v) => setTopTab(v as TopTab)}
>
<TabsList> <TabsList>
<TabsTrigger value="list">List</TabsTrigger> <TabsTrigger value="schedule">Schedule</TabsTrigger>
<TabsTrigger value="gantt">Gantt</TabsTrigger> <TabsTrigger value="baseline">Baseline</TabsTrigger>
<TabsTrigger value="exceptions">
Workday Exceptions
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="schedule" className="mt-0">
<ScheduleToolbar onNewItem={() => setTaskFormOpen(true)} />
<Tabs
value={subTab}
onValueChange={(v) => setSubTab(v as ScheduleSubTab)}
>
<TabsList className="bg-transparent border-b rounded-none h-auto p-0 gap-4">
<TabsTrigger
value="calendar"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
>
Calendar
</TabsTrigger>
<TabsTrigger
value="list"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
>
List
</TabsTrigger>
<TabsTrigger
value="gantt"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
>
Gantt
</TabsTrigger>
</TabsList>
<TabsContent value="calendar" className="mt-4">
<ScheduleCalendarView
projectId={projectId}
tasks={initialData.tasks}
exceptions={initialData.exceptions}
/>
</TabsContent>
<TabsContent value="list" className="mt-4"> <TabsContent value="list" className="mt-4">
<ScheduleListView <ScheduleListView
projectId={projectId} projectId={projectId}
@ -47,6 +106,30 @@ export function ScheduleView({
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</TabsContent>
<TabsContent value="baseline" className="mt-4">
<ScheduleBaselineView
projectId={projectId}
baselines={baselines}
currentTasks={initialData.tasks}
/>
</TabsContent>
<TabsContent value="exceptions" className="mt-4">
<WorkdayExceptionsView
projectId={projectId}
exceptions={initialData.exceptions}
/>
</TabsContent>
</Tabs>
<TaskFormDialog
open={taskFormOpen}
onOpenChange={setTaskFormOpen}
projectId={projectId}
editingTask={null}
/>
</div> </div>
) )
} }

View File

@ -19,6 +19,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Slider } from "@/components/ui/slider"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -53,9 +54,11 @@ const phases: { value: ConstructionPhase; label: string }[] = [
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
startDate: z.string().min(1, "Start date is required"), startDate: z.string().min(1, "Start date is required"),
workdays: z.coerce.number().min(1, "Must be at least 1 day"), workdays: z.number().min(1, "Must be at least 1 day"),
phase: z.string().min(1, "Phase is required"), phase: z.string().min(1, "Phase is required"),
isMilestone: z.boolean(), isMilestone: z.boolean(),
percentComplete: z.number().min(0).max(100),
assignedTo: z.string(),
}) })
type TaskFormValues = z.infer<typeof taskSchema> type TaskFormValues = z.infer<typeof taskSchema>
@ -84,6 +87,8 @@ export function TaskFormDialog({
workdays: 5, workdays: 5,
phase: "preconstruction", phase: "preconstruction",
isMilestone: false, isMilestone: false,
percentComplete: 0,
assignedTo: "",
}, },
}) })
@ -95,6 +100,8 @@ export function TaskFormDialog({
workdays: editingTask.workdays, workdays: editingTask.workdays,
phase: editingTask.phase, phase: editingTask.phase,
isMilestone: editingTask.isMilestone, isMilestone: editingTask.isMilestone,
percentComplete: editingTask.percentComplete,
assignedTo: editingTask.assignedTo ?? "",
}) })
} else { } else {
form.reset({ form.reset({
@ -103,6 +110,8 @@ export function TaskFormDialog({
workdays: 5, workdays: 5,
phase: "preconstruction", phase: "preconstruction",
isMilestone: false, isMilestone: false,
percentComplete: 0,
assignedTo: "",
}) })
} }
}, [editingTask, form]) }, [editingTask, form])
@ -118,9 +127,15 @@ export function TaskFormDialog({
async function onSubmit(values: TaskFormValues) { async function onSubmit(values: TaskFormValues) {
let result let result
if (isEditing) { if (isEditing) {
result = await updateTask(editingTask.id, values) result = await updateTask(editingTask.id, {
...values,
assignedTo: values.assignedTo || null,
})
} else { } else {
result = await createTask(projectId, values) result = await createTask(projectId, {
...values,
assignedTo: values.assignedTo || undefined,
})
} }
if (result.success) { if (result.success) {
@ -178,7 +193,17 @@ export function TaskFormDialog({
<FormItem> <FormItem>
<FormLabel>Workdays</FormLabel> <FormLabel>Workdays</FormLabel>
<FormControl> <FormControl>
<Input type="number" min={1} {...field} /> <Input
type="number"
min={1}
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value) || 0)
}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -220,6 +245,42 @@ export function TaskFormDialog({
)} )}
/> />
<FormField
control={form.control}
name="percentComplete"
render={({ field }) => (
<FormItem>
<FormLabel>
Complete: {field.value}%
</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={5}
value={[field.value]}
onValueChange={([val]) => field.onChange(val)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="assignedTo"
render={({ field }) => (
<FormItem>
<FormLabel>Assigned To</FormLabel>
<FormControl>
<Input placeholder="Person name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="isMilestone" name="isMilestone"

View File

@ -0,0 +1,294 @@
"use client"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import {
createWorkdayException,
updateWorkdayException,
} from "@/app/actions/workday-exceptions"
import type {
WorkdayExceptionData,
ExceptionCategory,
ExceptionRecurrence,
} from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
const categories: { value: ExceptionCategory; label: string }[] = [
{ value: "national_holiday", label: "National Holiday" },
{ value: "state_holiday", label: "State Holiday" },
{ value: "vacation_day", label: "Vacation Day" },
{ value: "company_holiday", label: "Company Holiday" },
{ value: "weather_day", label: "Weather Day" },
]
const recurrences: { value: ExceptionRecurrence; label: string }[] = [
{ value: "one_time", label: "One Time" },
{ value: "yearly", label: "Yearly" },
]
const exceptionSchema = z.object({
title: z.string().min(1, "Title is required"),
startDate: z.string().min(1, "Start date is required"),
endDate: z.string().min(1, "End date is required"),
type: z.string().min(1),
category: z.string().min(1),
recurrence: z.string().min(1),
notes: z.string(),
})
type ExceptionFormValues = z.infer<typeof exceptionSchema>
interface ExceptionFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
editingException: WorkdayExceptionData | null
}
export function WorkdayExceptionFormDialog({
open,
onOpenChange,
projectId,
editingException,
}: ExceptionFormDialogProps) {
const router = useRouter()
const isEditing = !!editingException
const form = useForm<ExceptionFormValues>({
resolver: zodResolver(exceptionSchema),
defaultValues: {
title: "",
startDate: new Date().toISOString().split("T")[0],
endDate: new Date().toISOString().split("T")[0],
type: "non_working",
category: "company_holiday",
recurrence: "one_time",
notes: "",
},
})
useEffect(() => {
if (editingException) {
form.reset({
title: editingException.title,
startDate: editingException.startDate,
endDate: editingException.endDate,
type: editingException.type,
category: editingException.category,
recurrence: editingException.recurrence,
notes: editingException.notes ?? "",
})
} else {
form.reset({
title: "",
startDate: new Date().toISOString().split("T")[0],
endDate: new Date().toISOString().split("T")[0],
type: "non_working",
category: "company_holiday",
recurrence: "one_time",
notes: "",
})
}
}, [editingException, form])
async function onSubmit(values: ExceptionFormValues) {
let result
if (isEditing) {
result = await updateWorkdayException(editingException.id, {
...values,
category: values.category as ExceptionCategory,
recurrence: values.recurrence as ExceptionRecurrence,
notes: values.notes || null,
})
} else {
result = await createWorkdayException(projectId, {
...values,
category: values.category as ExceptionCategory,
recurrence: values.recurrence as ExceptionRecurrence,
notes: values.notes || undefined,
})
}
if (result.success) {
onOpenChange(false)
router.refresh()
} else {
toast.error(result.error)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Exception" : "New Workday Exception"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="e.g. Christmas Day" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>End Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recurrence"
render={({ field }) => (
<FormItem>
<FormLabel>Recurrence</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{recurrences.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Optional notes..."
className="resize-none"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? "Save" : "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,174 @@
"use client"
import { useState, useCallback } from "react"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { IconPlus, IconPencil, IconTrash } from "@tabler/icons-react"
import { WorkdayExceptionFormDialog } from "./workday-exception-form-dialog"
import { deleteWorkdayException } from "@/app/actions/workday-exceptions"
import type { WorkdayExceptionData } from "@/lib/schedule/types"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { format, parseISO } from "date-fns"
interface WorkdayExceptionsViewProps {
projectId: string
exceptions: WorkdayExceptionData[]
}
function formatDate(dateStr: string): string {
try {
return format(parseISO(dateStr), "MMM d, yyyy")
} catch {
return dateStr
}
}
function calcDuration(start: string, end: string): number {
const s = parseISO(start)
const e = parseISO(end)
const diff = Math.ceil(
(e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24)
)
return diff + 1
}
const categoryLabels: Record<string, string> = {
national_holiday: "National Holiday",
state_holiday: "State Holiday",
vacation_day: "Vacation Day",
company_holiday: "Company Holiday",
weather_day: "Weather Day",
}
export function WorkdayExceptionsView({
projectId,
exceptions,
}: WorkdayExceptionsViewProps) {
const router = useRouter()
const [formOpen, setFormOpen] = useState(false)
const [editingException, setEditingException] =
useState<WorkdayExceptionData | null>(null)
const handleDelete = useCallback(
async (id: string) => {
const result = await deleteWorkdayException(id)
if (result.success) {
router.refresh()
} else {
toast.error(result.error)
}
},
[router]
)
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium">Workday Exceptions</h2>
<Button
size="sm"
onClick={() => {
setEditingException(null)
setFormOpen(true)
}}
>
<IconPlus className="size-4 mr-1" />
Workday Exception
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Category</TableHead>
<TableHead>Recurrence</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{exceptions.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center py-8 text-muted-foreground"
>
No workday exceptions defined.
</TableCell>
</TableRow>
) : (
exceptions.map((ex) => (
<TableRow key={ex.id}>
<TableCell className="font-medium text-sm">
{ex.title}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(ex.startDate)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(ex.endDate)}
</TableCell>
<TableCell className="text-xs">
{calcDuration(ex.startDate, ex.endDate)} days
</TableCell>
<TableCell className="text-xs">
{categoryLabels[ex.category] ?? ex.category}
</TableCell>
<TableCell className="text-xs capitalize">
{ex.recurrence.replace("_", " ")}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[120px]">
{ex.notes || "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => {
setEditingException(ex)
setFormOpen(true)
}}
>
<IconPencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => handleDelete(ex.id)}
>
<IconTrash className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<WorkdayExceptionFormDialog
open={formOpen}
onOpenChange={setFormOpen}
projectId={projectId}
editingException={editingException}
/>
</div>
)
}

View File

@ -2,16 +2,16 @@
import * as React from "react" import * as React from "react"
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels" import { Group, Panel, Separator } from "react-resizable-panels"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) { }: React.ComponentProps<typeof Group>) {
return ( return (
<ResizablePrimitive.PanelGroup <Group
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
@ -24,19 +24,19 @@ function ResizablePanelGroup({
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <Panel data-slot="resizable-panel" {...props} />
} }
function ResizableHandle({ function ResizableHandle({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof Separator> & {
withHandle?: boolean withHandle?: boolean
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
@ -49,7 +49,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" /> <GripVerticalIcon className="size-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </Separator>
) )
} }

View File

@ -27,6 +27,8 @@ export const scheduleTasks = sqliteTable("schedule_tasks", {
isMilestone: integer("is_milestone", { mode: "boolean" }) isMilestone: integer("is_milestone", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
percentComplete: integer("percent_complete").notNull().default(0),
assignedTo: text("assigned_to"),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
@ -44,8 +46,38 @@ export const taskDependencies = sqliteTable("task_dependencies", {
lagDays: integer("lag_days").notNull().default(0), lagDays: integer("lag_days").notNull().default(0),
}) })
export const workdayExceptions = sqliteTable("workday_exceptions", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
title: text("title").notNull(),
startDate: text("start_date").notNull(),
endDate: text("end_date").notNull(),
type: text("type").notNull().default("non_working"),
category: text("category").notNull().default("company_holiday"),
recurrence: text("recurrence").notNull().default("one_time"),
notes: text("notes"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
})
export const scheduleBaselines = sqliteTable("schedule_baselines", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
snapshotData: text("snapshot_data").notNull(),
createdAt: text("created_at").notNull(),
})
export type Project = typeof projects.$inferSelect export type Project = typeof projects.$inferSelect
export type ScheduleTask = typeof scheduleTasks.$inferSelect export type ScheduleTask = typeof scheduleTasks.$inferSelect
export type NewScheduleTask = typeof scheduleTasks.$inferInsert export type NewScheduleTask = typeof scheduleTasks.$inferInsert
export type TaskDependency = typeof taskDependencies.$inferSelect export type TaskDependency = typeof taskDependencies.$inferSelect
export type NewTaskDependency = typeof taskDependencies.$inferInsert export type NewTaskDependency = typeof taskDependencies.$inferInsert
export type WorkdayException = typeof workdayExceptions.$inferSelect
export type NewWorkdayException = typeof workdayExceptions.$inferInsert
export type ScheduleBaseline = typeof scheduleBaselines.$inferSelect
export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert

View File

@ -1,22 +1,41 @@
import { addDays, isWeekend, parseISO, format } from "date-fns" import { addDays, isWeekend, parseISO, format, isWithinInterval } from "date-fns"
import type { WorkdayExceptionData } from "./types"
function isExceptionDay(
date: Date,
exceptions: WorkdayExceptionData[]
): boolean {
return exceptions.some((ex) => {
const start = parseISO(ex.startDate)
const end = parseISO(ex.endDate)
return isWithinInterval(date, { start, end })
})
}
function isNonWorkday(
date: Date,
exceptions: WorkdayExceptionData[] = []
): boolean {
return isWeekend(date) || isExceptionDay(date, exceptions)
}
export function calculateEndDate( export function calculateEndDate(
startDate: string, startDate: string,
workdays: number workdays: number,
exceptions: WorkdayExceptionData[] = []
): string { ): string {
if (workdays <= 0) return startDate if (workdays <= 0) return startDate
let current = parseISO(startDate) let current = parseISO(startDate)
let remaining = workdays let remaining = workdays
// start date counts as day 1 if it's a business day if (!isNonWorkday(current, exceptions)) {
if (!isWeekend(current)) {
remaining-- remaining--
} }
while (remaining > 0) { while (remaining > 0) {
current = addDays(current, 1) current = addDays(current, 1)
if (!isWeekend(current)) { if (!isNonWorkday(current, exceptions)) {
remaining-- remaining--
} }
} }
@ -26,14 +45,15 @@ export function calculateEndDate(
export function countBusinessDays( export function countBusinessDays(
startDate: string, startDate: string,
endDate: string endDate: string,
exceptions: WorkdayExceptionData[] = []
): number { ): number {
let current = parseISO(startDate) let current = parseISO(startDate)
const end = parseISO(endDate) const end = parseISO(endDate)
let count = 0 let count = 0
while (current <= end) { while (current <= end) {
if (!isWeekend(current)) { if (!isNonWorkday(current, exceptions)) {
count++ count++
} }
current = addDays(current, 1) current = addDays(current, 1)
@ -44,7 +64,8 @@ export function countBusinessDays(
export function addBusinessDays( export function addBusinessDays(
date: string, date: string,
days: number days: number,
exceptions: WorkdayExceptionData[] = []
): string { ): string {
let current = parseISO(date) let current = parseISO(date)
let remaining = Math.abs(days) let remaining = Math.abs(days)
@ -52,7 +73,7 @@ export function addBusinessDays(
while (remaining > 0) { while (remaining > 0) {
current = addDays(current, direction) current = addDays(current, direction)
if (!isWeekend(current)) { if (!isNonWorkday(current, exceptions)) {
remaining-- remaining--
} }
} }

View File

@ -1,4 +1,8 @@
import type { ScheduleTaskData, TaskDependencyData } from "./types" import type {
ScheduleTaskData,
TaskDependencyData,
WorkdayExceptionData,
} from "./types"
import { calculateEndDate, addBusinessDays } from "./business-days" import { calculateEndDate, addBusinessDays } from "./business-days"
interface PropagationResult { interface PropagationResult {
@ -8,7 +12,8 @@ interface PropagationResult {
export function propagateDates( export function propagateDates(
changedTaskId: string, changedTaskId: string,
tasks: ScheduleTaskData[], tasks: ScheduleTaskData[],
dependencies: TaskDependencyData[] dependencies: TaskDependencyData[],
exceptions: WorkdayExceptionData[] = []
): PropagationResult { ): PropagationResult {
const taskMap = new Map(tasks.map((t) => [t.id, { ...t }])) const taskMap = new Map(tasks.map((t) => [t.id, { ...t }]))
const updates = new Map<string, { startDate: string; endDateCalculated: string }>() const updates = new Map<string, { startDate: string; endDateCalculated: string }>()
@ -43,9 +48,10 @@ export function propagateDates(
// successor starts after predecessor ends + lag // successor starts after predecessor ends + lag
const newStart = addBusinessDays( const newStart = addBusinessDays(
current.endDateCalculated, current.endDateCalculated,
1 + dep.lagDays 1 + dep.lagDays,
exceptions
) )
const newEnd = calculateEndDate(newStart, successor.workdays) const newEnd = calculateEndDate(newStart, successor.workdays, exceptions)
if (newStart !== successor.startDate || newEnd !== successor.endDateCalculated) { if (newStart !== successor.startDate || newEnd !== successor.endDateCalculated) {
successor.startDate = newStart successor.startDate = newStart

View File

@ -17,6 +17,15 @@ export type ConstructionPhase =
| "landscaping" | "landscaping"
| "closeout" | "closeout"
export type ExceptionCategory =
| "national_holiday"
| "state_holiday"
| "vacation_day"
| "company_holiday"
| "weather_day"
export type ExceptionRecurrence = "one_time" | "yearly"
export interface ScheduleTaskData { export interface ScheduleTaskData {
id: string id: string
projectId: string projectId: string
@ -28,6 +37,8 @@ export interface ScheduleTaskData {
status: TaskStatus status: TaskStatus
isCriticalPath: boolean isCriticalPath: boolean
isMilestone: boolean isMilestone: boolean
percentComplete: number
assignedTo: string | null
sortOrder: number sortOrder: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@ -41,7 +52,30 @@ export interface TaskDependencyData {
lagDays: number lagDays: number
} }
export interface WorkdayExceptionData {
id: string
projectId: string
title: string
startDate: string
endDate: string
type: string
category: ExceptionCategory
recurrence: ExceptionRecurrence
notes: string | null
createdAt: string
updatedAt: string
}
export interface ScheduleBaselineData {
id: string
projectId: string
name: string
snapshotData: string
createdAt: string
}
export interface ScheduleData { export interface ScheduleData {
tasks: ScheduleTaskData[] tasks: ScheduleTaskData[]
dependencies: TaskDependencyData[] dependencies: TaskDependencyData[]
exceptions: WorkdayExceptionData[]
} }