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:
parent
67fed00bbd
commit
aa6230c9d4
26
drizzle/0001_gorgeous_sebastian_shaw.sql
Executable file
26
drizzle/0001_gorgeous_sebastian_shaw.sql
Executable 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
420
drizzle/meta/0001_snapshot.json
Executable 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": {}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,13 @@
|
||||
"when": 1769219785552,
|
||||
"tag": "0000_nice_sabra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1769222679525,
|
||||
"tag": "0001_gorgeous_sebastian_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
91
src/app/actions/baselines.ts
Executable file
91
src/app/actions/baselines.ts
Executable 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" }
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,12 @@
|
||||
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
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 { revalidatePath } from "next/cache"
|
||||
import { calculateEndDate } from "@/lib/schedule/business-days"
|
||||
@ -12,9 +17,28 @@ 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> {
|
||||
@ -28,8 +52,8 @@ export async function getSchedule(
|
||||
.orderBy(asc(scheduleTasks.sortOrder))
|
||||
|
||||
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 projectDeps = deps.filter(
|
||||
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
|
||||
@ -45,6 +69,7 @@ export async function getSchedule(
|
||||
...d,
|
||||
type: d.type as DependencyType,
|
||||
})),
|
||||
exceptions,
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,16 +81,20 @@ export async function createTask(
|
||||
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 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()
|
||||
|
||||
// get next sort order
|
||||
const existing = await db
|
||||
.select({ sortOrder: scheduleTasks.sortOrder })
|
||||
.from(scheduleTasks)
|
||||
@ -88,6 +117,8 @@ export async function createTask(
|
||||
status: "PENDING",
|
||||
isCriticalPath: false,
|
||||
isMilestone: data.isMilestone ?? false,
|
||||
percentComplete: data.percentComplete ?? 0,
|
||||
assignedTo: data.assignedTo ?? null,
|
||||
sortOrder: nextOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@ -110,6 +141,8 @@ export async function updateTask(
|
||||
workdays?: number
|
||||
phase?: string
|
||||
isMilestone?: boolean
|
||||
percentComplete?: number
|
||||
assignedTo?: string | null
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
@ -124,9 +157,10 @@ export async function updateTask(
|
||||
|
||||
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)
|
||||
const endDate = calculateEndDate(startDate, workdays, exceptions)
|
||||
|
||||
await db
|
||||
.update(scheduleTasks)
|
||||
@ -136,18 +170,34 @@ export async function updateTask(
|
||||
workdays,
|
||||
endDateCalculated: endDate,
|
||||
...(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(),
|
||||
})
|
||||
.where(eq(scheduleTasks.id, taskId))
|
||||
|
||||
// propagate date changes to downstream tasks
|
||||
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) =>
|
||||
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) {
|
||||
await db
|
||||
@ -248,7 +298,8 @@ export async function createDependency(data: {
|
||||
const { updatedTasks } = propagateDates(
|
||||
data.predecessorId,
|
||||
updatedSchedule.tasks,
|
||||
updatedSchedule.dependencies
|
||||
updatedSchedule.dependencies,
|
||||
updatedSchedule.exceptions
|
||||
)
|
||||
|
||||
for (const [id, dates] of updatedTasks) {
|
||||
|
||||
146
src/app/actions/workday-exceptions.ts
Executable file
146
src/app/actions/workday-exceptions.ts
Executable 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" }
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,15 @@ import { projects } from "@/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getSchedule } from "@/app/actions/schedule"
|
||||
import { getBaselines } from "@/app/actions/baselines"
|
||||
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({
|
||||
params,
|
||||
@ -12,25 +20,41 @@ export default async function SchedulePage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.limit(1)
|
||||
let projectName = "Project"
|
||||
let schedule: ScheduleData = emptySchedule
|
||||
let baselines: ScheduleBaselineData[] = []
|
||||
|
||||
if (!project) notFound()
|
||||
try {
|
||||
const { env } = await getCloudflareContext()
|
||||
if (!env?.DB) throw new Error("D1 not available")
|
||||
|
||||
const schedule = await getSchedule(id)
|
||||
const db = getDb(env.DB)
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!project) notFound()
|
||||
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<ScheduleView
|
||||
projectId={id}
|
||||
projectName={project.name}
|
||||
projectName={projectName}
|
||||
initialData={schedule}
|
||||
baselines={baselines}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
274
src/components/schedule/schedule-baseline-view.tsx
Executable file
274
src/components/schedule/schedule-baseline-view.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
233
src/components/schedule/schedule-calendar-view.tsx
Executable file
233
src/components/schedule/schedule-calendar-view.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
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 "./gantt.css"
|
||||
import { TaskFormDialog } from "./task-form-dialog"
|
||||
import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform"
|
||||
import { updateTask } from "@/app/actions/schedule"
|
||||
import { countBusinessDays } from "@/lib/schedule/business-days"
|
||||
@ -31,8 +46,18 @@ export function ScheduleGanttView({
|
||||
}: ScheduleGanttViewProps) {
|
||||
const router = useRouter()
|
||||
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(
|
||||
async (task: FrappeTask, start: Date, end: Date) => {
|
||||
@ -54,25 +79,152 @@ export function ScheduleGanttView({
|
||||
[router]
|
||||
)
|
||||
|
||||
const scrollToToday = () => {
|
||||
const todayEl = document.querySelector(".gantt-container .today-highlight")
|
||||
if (todayEl) {
|
||||
todayEl.scrollIntoView({ behavior: "smooth", inline: "center" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
size="sm"
|
||||
variant={viewMode === mode ? "default" : "outline"}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
{mode}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
key={mode}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant={viewMode === mode ? "default" : "outline"}
|
||||
onClick={() => setViewMode(mode)}
|
||||
onClick={scrollToToday}
|
||||
>
|
||||
{mode}
|
||||
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>
|
||||
|
||||
<GanttChart
|
||||
tasks={frappeTasks}
|
||||
viewMode={viewMode}
|
||||
onDateChange={handleDateChange}
|
||||
<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
|
||||
tasks={frappeTasks}
|
||||
viewMode={viewMode}
|
||||
onDateChange={handleDateChange}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<TaskFormDialog
|
||||
open={taskFormOpen}
|
||||
onOpenChange={setTaskFormOpen}
|
||||
projectId={projectId}
|
||||
editingTask={editingTask}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getGroupedRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
@ -17,45 +17,12 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
IconPlus,
|
||||
IconLink,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconLink,
|
||||
} 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -63,6 +30,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 {
|
||||
projectId: string
|
||||
@ -70,49 +47,87 @@ interface ScheduleListViewProps {
|
||||
dependencies: TaskDependencyData[]
|
||||
}
|
||||
|
||||
const statusOptions: { value: TaskStatus; label: string }[] = [
|
||||
{ value: "PENDING", label: "Pending" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "COMPLETE", label: "Complete" },
|
||||
{ value: "BLOCKED", label: "Blocked" },
|
||||
]
|
||||
function StatusDot({ task }: { task: ScheduleTaskData }) {
|
||||
let color = "bg-gray-400"
|
||||
if (task.status === "COMPLETE") color = "bg-green-500"
|
||||
else if (task.status === "IN_PROGRESS") color = "bg-blue-500"
|
||||
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,
|
||||
function ProgressRing({
|
||||
percent,
|
||||
size = 28,
|
||||
}: {
|
||||
row: { id: string }
|
||||
children: React.ReactNode
|
||||
percent: number
|
||||
size?: number
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
const stroke = 3
|
||||
const radius = (size - stroke) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (percent / 100) * circumference
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style}>
|
||||
<TableCell className="w-8">
|
||||
<IconGripVertical
|
||||
className="size-4 text-muted-foreground cursor-grab"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
className="text-muted-foreground/20"
|
||||
/>
|
||||
</TableCell>
|
||||
{children}
|
||||
</TableRow>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
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({
|
||||
projectId,
|
||||
tasks,
|
||||
@ -123,30 +138,12 @@ export function ScheduleListView({
|
||||
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(null)
|
||||
const [depDialogOpen, setDepDialogOpen] = useState(false)
|
||||
const [localTasks, setLocalTasks] = useState(tasks)
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTasks(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(
|
||||
async (taskId: string) => {
|
||||
const result = await deleteTask(taskId)
|
||||
@ -159,147 +156,148 @@ export function ScheduleListView({
|
||||
[router]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = localTasks.findIndex((t) => t.id === active.id)
|
||||
const newIndex = localTasks.findIndex((t) => t.id === over.id)
|
||||
const reordered = arrayMove(localTasks, oldIndex, newIndex)
|
||||
setLocalTasks(reordered)
|
||||
|
||||
const items = reordered.map((t, i) => ({ id: t.id, sortOrder: i }))
|
||||
await reorderTasks(projectId, items)
|
||||
router.refresh()
|
||||
},
|
||||
[localTasks, projectId, router]
|
||||
)
|
||||
|
||||
const columns: ColumnDef<ScheduleTaskData>[] = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Task",
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
className="text-left font-medium hover:underline"
|
||||
onClick={() => {
|
||||
setEditingTask(row.original)
|
||||
setTaskFormOpen(true)
|
||||
}}
|
||||
>
|
||||
{row.original.title}
|
||||
{row.original.isMilestone && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">◆</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "startDate",
|
||||
header: "Start",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.startDate}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "endDateCalculated",
|
||||
header: "End",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.endDateCalculated}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "workdays",
|
||||
header: "Days",
|
||||
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>
|
||||
)
|
||||
const columns: ColumnDef<ScheduleTaskData>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
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 }) =>
|
||||
row.original.isCriticalPath ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Critical
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => handleDelete(row.original.id)}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "idNum",
|
||||
header: "#",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.sortOrder + 1}
|
||||
</span>
|
||||
),
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot task={row.original} />
|
||||
<span className="font-medium text-sm truncate max-w-[200px]">
|
||||
{row.original.title}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
header: "Start",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(row.original.startDate)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "endDateCalculated",
|
||||
header: "End",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(row.original.endDateCalculated)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "assignedTo",
|
||||
header: "Assigned To",
|
||||
cell: ({ row }) =>
|
||||
row.original.assignedTo ? (
|
||||
<InitialsAvatar name={row.original.assignedTo} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => handleDelete(row.original.id)}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
size: 80,
|
||||
},
|
||||
],
|
||||
[handleDelete]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: localTasks,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: { rowSelection },
|
||||
initialState: { pagination: { pageSize: 25 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@ -312,61 +310,96 @@ export function ScheduleListView({
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableHead className="w-8" />
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No tasks yet. Click "New Schedule Item" to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={localTasks.map((t) => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No tasks yet. Click "Add Task" to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<SortableRow key={row.id} row={row}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</SortableRow>
|
||||
))
|
||||
)}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
<TaskFormDialog
|
||||
|
||||
70
src/components/schedule/schedule-toolbar.tsx
Executable file
70
src/components/schedule/schedule-toolbar.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -2,51 +2,134 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ScheduleToolbar } from "./schedule-toolbar"
|
||||
import { ScheduleListView } from "./schedule-list-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 {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialData: ScheduleData
|
||||
baselines: ScheduleBaselineData[]
|
||||
}
|
||||
|
||||
export function ScheduleView({
|
||||
projectId,
|
||||
projectName,
|
||||
initialData,
|
||||
baselines,
|
||||
}: ScheduleViewProps) {
|
||||
const [activeTab, setActiveTab] = useState("list")
|
||||
const [topTab, setTopTab] = useState<TopTab>("schedule")
|
||||
const [subTab, setSubTab] = useState<ScheduleSubTab>("list")
|
||||
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Tabs
|
||||
value={topTab}
|
||||
onValueChange={(v) => setTopTab(v as TopTab)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="list">List</TabsTrigger>
|
||||
<TabsTrigger value="gantt">Gantt</TabsTrigger>
|
||||
<TabsTrigger value="schedule">Schedule</TabsTrigger>
|
||||
<TabsTrigger value="baseline">Baseline</TabsTrigger>
|
||||
<TabsTrigger value="exceptions">
|
||||
Workday Exceptions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="mt-4">
|
||||
<ScheduleListView
|
||||
<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">
|
||||
<ScheduleListView
|
||||
projectId={projectId}
|
||||
tasks={initialData.tasks}
|
||||
dependencies={initialData.dependencies}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gantt" className="mt-4">
|
||||
<ScheduleGanttView
|
||||
projectId={projectId}
|
||||
tasks={initialData.tasks}
|
||||
dependencies={initialData.dependencies}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="baseline" className="mt-4">
|
||||
<ScheduleBaselineView
|
||||
projectId={projectId}
|
||||
tasks={initialData.tasks}
|
||||
dependencies={initialData.dependencies}
|
||||
baselines={baselines}
|
||||
currentTasks={initialData.tasks}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gantt" className="mt-4">
|
||||
<ScheduleGanttView
|
||||
<TabsContent value="exceptions" className="mt-4">
|
||||
<WorkdayExceptionsView
|
||||
projectId={projectId}
|
||||
tasks={initialData.tasks}
|
||||
dependencies={initialData.dependencies}
|
||||
exceptions={initialData.exceptions}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TaskFormDialog
|
||||
open={taskFormOpen}
|
||||
onOpenChange={setTaskFormOpen}
|
||||
projectId={projectId}
|
||||
editingTask={null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -53,9 +54,11 @@ const phases: { value: ConstructionPhase; label: string }[] = [
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, "Title 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"),
|
||||
isMilestone: z.boolean(),
|
||||
percentComplete: z.number().min(0).max(100),
|
||||
assignedTo: z.string(),
|
||||
})
|
||||
|
||||
type TaskFormValues = z.infer<typeof taskSchema>
|
||||
@ -84,6 +87,8 @@ export function TaskFormDialog({
|
||||
workdays: 5,
|
||||
phase: "preconstruction",
|
||||
isMilestone: false,
|
||||
percentComplete: 0,
|
||||
assignedTo: "",
|
||||
},
|
||||
})
|
||||
|
||||
@ -95,6 +100,8 @@ export function TaskFormDialog({
|
||||
workdays: editingTask.workdays,
|
||||
phase: editingTask.phase,
|
||||
isMilestone: editingTask.isMilestone,
|
||||
percentComplete: editingTask.percentComplete,
|
||||
assignedTo: editingTask.assignedTo ?? "",
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
@ -103,6 +110,8 @@ export function TaskFormDialog({
|
||||
workdays: 5,
|
||||
phase: "preconstruction",
|
||||
isMilestone: false,
|
||||
percentComplete: 0,
|
||||
assignedTo: "",
|
||||
})
|
||||
}
|
||||
}, [editingTask, form])
|
||||
@ -118,9 +127,15 @@ export function TaskFormDialog({
|
||||
async function onSubmit(values: TaskFormValues) {
|
||||
let result
|
||||
if (isEditing) {
|
||||
result = await updateTask(editingTask.id, values)
|
||||
result = await updateTask(editingTask.id, {
|
||||
...values,
|
||||
assignedTo: values.assignedTo || null,
|
||||
})
|
||||
} else {
|
||||
result = await createTask(projectId, values)
|
||||
result = await createTask(projectId, {
|
||||
...values,
|
||||
assignedTo: values.assignedTo || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
@ -178,7 +193,17 @@ export function TaskFormDialog({
|
||||
<FormItem>
|
||||
<FormLabel>Workdays</FormLabel>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</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
|
||||
control={form.control}
|
||||
name="isMilestone"
|
||||
|
||||
294
src/components/schedule/workday-exception-form-dialog.tsx
Executable file
294
src/components/schedule/workday-exception-form-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
174
src/components/schedule/workday-exceptions-view.tsx
Executable file
174
src/components/schedule/workday-exceptions-view.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -2,16 +2,16 @@
|
||||
|
||||
import * as React from "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"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
}: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
@ -24,19 +24,19 @@ function ResizablePanelGroup({
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
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",
|
||||
@ -49,7 +49,7 @@ function ResizableHandle({
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
</Separator>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,8 @@ export const scheduleTasks = sqliteTable("schedule_tasks", {
|
||||
isMilestone: integer("is_milestone", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
percentComplete: integer("percent_complete").notNull().default(0),
|
||||
assignedTo: text("assigned_to"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
@ -44,8 +46,38 @@ export const taskDependencies = sqliteTable("task_dependencies", {
|
||||
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 ScheduleTask = typeof scheduleTasks.$inferSelect
|
||||
export type NewScheduleTask = typeof scheduleTasks.$inferInsert
|
||||
export type TaskDependency = typeof taskDependencies.$inferSelect
|
||||
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
|
||||
|
||||
@ -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(
|
||||
startDate: string,
|
||||
workdays: number
|
||||
workdays: number,
|
||||
exceptions: WorkdayExceptionData[] = []
|
||||
): string {
|
||||
if (workdays <= 0) return startDate
|
||||
|
||||
let current = parseISO(startDate)
|
||||
let remaining = workdays
|
||||
|
||||
// start date counts as day 1 if it's a business day
|
||||
if (!isWeekend(current)) {
|
||||
if (!isNonWorkday(current, exceptions)) {
|
||||
remaining--
|
||||
}
|
||||
|
||||
while (remaining > 0) {
|
||||
current = addDays(current, 1)
|
||||
if (!isWeekend(current)) {
|
||||
if (!isNonWorkday(current, exceptions)) {
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
@ -26,14 +45,15 @@ export function calculateEndDate(
|
||||
|
||||
export function countBusinessDays(
|
||||
startDate: string,
|
||||
endDate: string
|
||||
endDate: string,
|
||||
exceptions: WorkdayExceptionData[] = []
|
||||
): number {
|
||||
let current = parseISO(startDate)
|
||||
const end = parseISO(endDate)
|
||||
let count = 0
|
||||
|
||||
while (current <= end) {
|
||||
if (!isWeekend(current)) {
|
||||
if (!isNonWorkday(current, exceptions)) {
|
||||
count++
|
||||
}
|
||||
current = addDays(current, 1)
|
||||
@ -44,7 +64,8 @@ export function countBusinessDays(
|
||||
|
||||
export function addBusinessDays(
|
||||
date: string,
|
||||
days: number
|
||||
days: number,
|
||||
exceptions: WorkdayExceptionData[] = []
|
||||
): string {
|
||||
let current = parseISO(date)
|
||||
let remaining = Math.abs(days)
|
||||
@ -52,7 +73,7 @@ export function addBusinessDays(
|
||||
|
||||
while (remaining > 0) {
|
||||
current = addDays(current, direction)
|
||||
if (!isWeekend(current)) {
|
||||
if (!isNonWorkday(current, exceptions)) {
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { ScheduleTaskData, TaskDependencyData } from "./types"
|
||||
import type {
|
||||
ScheduleTaskData,
|
||||
TaskDependencyData,
|
||||
WorkdayExceptionData,
|
||||
} from "./types"
|
||||
import { calculateEndDate, addBusinessDays } from "./business-days"
|
||||
|
||||
interface PropagationResult {
|
||||
@ -8,7 +12,8 @@ interface PropagationResult {
|
||||
export function propagateDates(
|
||||
changedTaskId: string,
|
||||
tasks: ScheduleTaskData[],
|
||||
dependencies: TaskDependencyData[]
|
||||
dependencies: TaskDependencyData[],
|
||||
exceptions: WorkdayExceptionData[] = []
|
||||
): PropagationResult {
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, { ...t }]))
|
||||
const updates = new Map<string, { startDate: string; endDateCalculated: string }>()
|
||||
@ -43,9 +48,10 @@ export function propagateDates(
|
||||
// successor starts after predecessor ends + lag
|
||||
const newStart = addBusinessDays(
|
||||
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) {
|
||||
successor.startDate = newStart
|
||||
|
||||
@ -17,6 +17,15 @@ export type ConstructionPhase =
|
||||
| "landscaping"
|
||||
| "closeout"
|
||||
|
||||
export type ExceptionCategory =
|
||||
| "national_holiday"
|
||||
| "state_holiday"
|
||||
| "vacation_day"
|
||||
| "company_holiday"
|
||||
| "weather_day"
|
||||
|
||||
export type ExceptionRecurrence = "one_time" | "yearly"
|
||||
|
||||
export interface ScheduleTaskData {
|
||||
id: string
|
||||
projectId: string
|
||||
@ -28,6 +37,8 @@ export interface ScheduleTaskData {
|
||||
status: TaskStatus
|
||||
isCriticalPath: boolean
|
||||
isMilestone: boolean
|
||||
percentComplete: number
|
||||
assignedTo: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@ -41,7 +52,30 @@ export interface TaskDependencyData {
|
||||
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 {
|
||||
tasks: ScheduleTaskData[]
|
||||
dependencies: TaskDependencyData[]
|
||||
exceptions: WorkdayExceptionData[]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user