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,
|
"when": 1769219785552,
|
||||||
"tag": "0000_nice_sabra",
|
"tag": "0000_nice_sabra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769222679525,
|
||||||
|
"tag": "0001_gorgeous_sebastian_shaw",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
91
src/app/actions/baselines.ts
Executable file
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 { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
import { getDb } from "@/db"
|
import { getDb } from "@/db"
|
||||||
import { scheduleTasks, taskDependencies, projects } from "@/db/schema"
|
import {
|
||||||
|
scheduleTasks,
|
||||||
|
taskDependencies,
|
||||||
|
workdayExceptions,
|
||||||
|
projects,
|
||||||
|
} from "@/db/schema"
|
||||||
import { eq, asc } from "drizzle-orm"
|
import { eq, asc } from "drizzle-orm"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { calculateEndDate } from "@/lib/schedule/business-days"
|
import { calculateEndDate } from "@/lib/schedule/business-days"
|
||||||
@ -12,9 +17,28 @@ import { propagateDates } from "@/lib/schedule/propagate-dates"
|
|||||||
import type {
|
import type {
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
DependencyType,
|
DependencyType,
|
||||||
|
ExceptionCategory,
|
||||||
|
ExceptionRecurrence,
|
||||||
ScheduleData,
|
ScheduleData,
|
||||||
|
WorkdayExceptionData,
|
||||||
} from "@/lib/schedule/types"
|
} from "@/lib/schedule/types"
|
||||||
|
|
||||||
|
async function fetchExceptions(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
projectId: string
|
||||||
|
): Promise<WorkdayExceptionData[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(workdayExceptions)
|
||||||
|
.where(eq(workdayExceptions.projectId, projectId))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
category: r.category as ExceptionCategory,
|
||||||
|
recurrence: r.recurrence as ExceptionRecurrence,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSchedule(
|
export async function getSchedule(
|
||||||
projectId: string
|
projectId: string
|
||||||
): Promise<ScheduleData> {
|
): Promise<ScheduleData> {
|
||||||
@ -28,8 +52,8 @@ export async function getSchedule(
|
|||||||
.orderBy(asc(scheduleTasks.sortOrder))
|
.orderBy(asc(scheduleTasks.sortOrder))
|
||||||
|
|
||||||
const deps = await db.select().from(taskDependencies)
|
const deps = await db.select().from(taskDependencies)
|
||||||
|
const exceptions = await fetchExceptions(db, projectId)
|
||||||
|
|
||||||
// filter deps to only include tasks in this project
|
|
||||||
const taskIds = new Set(tasks.map((t) => t.id))
|
const taskIds = new Set(tasks.map((t) => t.id))
|
||||||
const projectDeps = deps.filter(
|
const projectDeps = deps.filter(
|
||||||
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
|
(d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId)
|
||||||
@ -45,6 +69,7 @@ export async function getSchedule(
|
|||||||
...d,
|
...d,
|
||||||
type: d.type as DependencyType,
|
type: d.type as DependencyType,
|
||||||
})),
|
})),
|
||||||
|
exceptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,16 +81,20 @@ export async function createTask(
|
|||||||
workdays: number
|
workdays: number
|
||||||
phase: string
|
phase: string
|
||||||
isMilestone?: boolean
|
isMilestone?: boolean
|
||||||
|
percentComplete?: number
|
||||||
|
assignedTo?: string
|
||||||
}
|
}
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
const endDate = calculateEndDate(data.startDate, data.workdays)
|
const exceptions = await fetchExceptions(db, projectId)
|
||||||
|
const endDate = calculateEndDate(
|
||||||
|
data.startDate, data.workdays, exceptions
|
||||||
|
)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
// get next sort order
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ sortOrder: scheduleTasks.sortOrder })
|
.select({ sortOrder: scheduleTasks.sortOrder })
|
||||||
.from(scheduleTasks)
|
.from(scheduleTasks)
|
||||||
@ -88,6 +117,8 @@ export async function createTask(
|
|||||||
status: "PENDING",
|
status: "PENDING",
|
||||||
isCriticalPath: false,
|
isCriticalPath: false,
|
||||||
isMilestone: data.isMilestone ?? false,
|
isMilestone: data.isMilestone ?? false,
|
||||||
|
percentComplete: data.percentComplete ?? 0,
|
||||||
|
assignedTo: data.assignedTo ?? null,
|
||||||
sortOrder: nextOrder,
|
sortOrder: nextOrder,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -110,6 +141,8 @@ export async function updateTask(
|
|||||||
workdays?: number
|
workdays?: number
|
||||||
phase?: string
|
phase?: string
|
||||||
isMilestone?: boolean
|
isMilestone?: boolean
|
||||||
|
percentComplete?: number
|
||||||
|
assignedTo?: string | null
|
||||||
}
|
}
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
@ -124,9 +157,10 @@ export async function updateTask(
|
|||||||
|
|
||||||
if (!task) return { success: false, error: "Task not found" }
|
if (!task) return { success: false, error: "Task not found" }
|
||||||
|
|
||||||
|
const exceptions = await fetchExceptions(db, task.projectId)
|
||||||
const startDate = data.startDate ?? task.startDate
|
const startDate = data.startDate ?? task.startDate
|
||||||
const workdays = data.workdays ?? task.workdays
|
const workdays = data.workdays ?? task.workdays
|
||||||
const endDate = calculateEndDate(startDate, workdays)
|
const endDate = calculateEndDate(startDate, workdays, exceptions)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(scheduleTasks)
|
.update(scheduleTasks)
|
||||||
@ -136,18 +170,34 @@ export async function updateTask(
|
|||||||
workdays,
|
workdays,
|
||||||
endDateCalculated: endDate,
|
endDateCalculated: endDate,
|
||||||
...(data.phase && { phase: data.phase }),
|
...(data.phase && { phase: data.phase }),
|
||||||
...(data.isMilestone !== undefined && { isMilestone: data.isMilestone }),
|
...(data.isMilestone !== undefined && {
|
||||||
|
isMilestone: data.isMilestone,
|
||||||
|
}),
|
||||||
|
...(data.percentComplete !== undefined && {
|
||||||
|
percentComplete: data.percentComplete,
|
||||||
|
}),
|
||||||
|
...(data.assignedTo !== undefined && {
|
||||||
|
assignedTo: data.assignedTo,
|
||||||
|
}),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(scheduleTasks.id, taskId))
|
.where(eq(scheduleTasks.id, taskId))
|
||||||
|
|
||||||
// propagate date changes to downstream tasks
|
// propagate date changes to downstream tasks
|
||||||
const schedule = await getSchedule(task.projectId)
|
const schedule = await getSchedule(task.projectId)
|
||||||
const updatedTask = { ...task, startDate, workdays, endDateCalculated: endDate }
|
const updatedTask = {
|
||||||
|
...task,
|
||||||
|
status: task.status as TaskStatus,
|
||||||
|
startDate,
|
||||||
|
workdays,
|
||||||
|
endDateCalculated: endDate,
|
||||||
|
}
|
||||||
const allTasks = schedule.tasks.map((t) =>
|
const allTasks = schedule.tasks.map((t) =>
|
||||||
t.id === taskId ? updatedTask : t
|
t.id === taskId ? updatedTask : t
|
||||||
)
|
)
|
||||||
const { updatedTasks } = propagateDates(taskId, allTasks, schedule.dependencies)
|
const { updatedTasks } = propagateDates(
|
||||||
|
taskId, allTasks, schedule.dependencies, exceptions
|
||||||
|
)
|
||||||
|
|
||||||
for (const [id, dates] of updatedTasks) {
|
for (const [id, dates] of updatedTasks) {
|
||||||
await db
|
await db
|
||||||
@ -248,7 +298,8 @@ export async function createDependency(data: {
|
|||||||
const { updatedTasks } = propagateDates(
|
const { updatedTasks } = propagateDates(
|
||||||
data.predecessorId,
|
data.predecessorId,
|
||||||
updatedSchedule.tasks,
|
updatedSchedule.tasks,
|
||||||
updatedSchedule.dependencies
|
updatedSchedule.dependencies,
|
||||||
|
updatedSchedule.exceptions
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const [id, dates] of updatedTasks) {
|
for (const [id, dates] of updatedTasks) {
|
||||||
|
|||||||
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 { eq } from "drizzle-orm"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getSchedule } from "@/app/actions/schedule"
|
import { getSchedule } from "@/app/actions/schedule"
|
||||||
|
import { getBaselines } from "@/app/actions/baselines"
|
||||||
import { ScheduleView } from "@/components/schedule/schedule-view"
|
import { ScheduleView } from "@/components/schedule/schedule-view"
|
||||||
|
import type { ScheduleData, ScheduleBaselineData } from "@/lib/schedule/types"
|
||||||
|
|
||||||
|
const emptySchedule: ScheduleData = {
|
||||||
|
tasks: [],
|
||||||
|
dependencies: [],
|
||||||
|
exceptions: [],
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SchedulePage({
|
export default async function SchedulePage({
|
||||||
params,
|
params,
|
||||||
@ -12,9 +20,16 @@ export default async function SchedulePage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { env } = await getCloudflareContext()
|
|
||||||
const db = getDb(env.DB)
|
|
||||||
|
|
||||||
|
let projectName = "Project"
|
||||||
|
let schedule: ScheduleData = emptySchedule
|
||||||
|
let baselines: ScheduleBaselineData[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
if (!env?.DB) throw new Error("D1 not available")
|
||||||
|
|
||||||
|
const db = getDb(env.DB)
|
||||||
const [project] = await db
|
const [project] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
@ -23,14 +38,23 @@ export default async function SchedulePage({
|
|||||||
|
|
||||||
if (!project) notFound()
|
if (!project) notFound()
|
||||||
|
|
||||||
const schedule = await getSchedule(id)
|
projectName = project.name
|
||||||
|
;[schedule, baselines] = await Promise.all([
|
||||||
|
getSchedule(id),
|
||||||
|
getBaselines(id),
|
||||||
|
])
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.digest === "NEXT_NOT_FOUND") throw e
|
||||||
|
console.warn("D1 unavailable in dev mode, using empty data")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<ScheduleView
|
<ScheduleView
|
||||||
projectId={id}
|
projectId={id}
|
||||||
projectName={project.name}
|
projectName={projectName}
|
||||||
initialData={schedule}
|
initialData={schedule}
|
||||||
|
baselines={baselines}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { IconPencil, IconPlus } from "@tabler/icons-react"
|
||||||
import { GanttChart } from "./gantt-chart"
|
import { GanttChart } from "./gantt-chart"
|
||||||
import "./gantt.css"
|
import { TaskFormDialog } from "./task-form-dialog"
|
||||||
import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform"
|
import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform"
|
||||||
import { updateTask } from "@/app/actions/schedule"
|
import { updateTask } from "@/app/actions/schedule"
|
||||||
import { countBusinessDays } from "@/lib/schedule/business-days"
|
import { countBusinessDays } from "@/lib/schedule/business-days"
|
||||||
@ -31,8 +46,18 @@ export function ScheduleGanttView({
|
|||||||
}: ScheduleGanttViewProps) {
|
}: ScheduleGanttViewProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("Week")
|
const [viewMode, setViewMode] = useState<ViewMode>("Week")
|
||||||
|
const [showPhases, setShowPhases] = useState(false)
|
||||||
|
const [showCriticalPath, setShowCriticalPath] = useState(false)
|
||||||
|
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
||||||
|
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
const frappeTasks = transformToFrappeTasks(tasks, dependencies)
|
const filteredTasks = showCriticalPath
|
||||||
|
? tasks.filter((t) => t.isCriticalPath)
|
||||||
|
: tasks
|
||||||
|
|
||||||
|
const frappeTasks = transformToFrappeTasks(filteredTasks, dependencies)
|
||||||
|
|
||||||
const handleDateChange = useCallback(
|
const handleDateChange = useCallback(
|
||||||
async (task: FrappeTask, start: Date, end: Date) => {
|
async (task: FrappeTask, start: Date, end: Date) => {
|
||||||
@ -54,9 +79,17 @@ export function ScheduleGanttView({
|
|||||||
[router]
|
[router]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const scrollToToday = () => {
|
||||||
|
const todayEl = document.querySelector(".gantt-container .today-highlight")
|
||||||
|
if (todayEl) {
|
||||||
|
todayEl.scrollIntoView({ behavior: "smooth", inline: "center" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
|
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
|
||||||
<Button
|
<Button
|
||||||
key={mode}
|
key={mode}
|
||||||
@ -67,13 +100,132 @@ export function ScheduleGanttView({
|
|||||||
{mode}
|
{mode}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={scrollToToday}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
checked={showPhases}
|
||||||
|
onCheckedChange={setShowPhases}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Phases
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
checked={showCriticalPath}
|
||||||
|
onCheckedChange={setShowCriticalPath}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Critical Path
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="border rounded-md min-h-[400px]"
|
||||||
|
>
|
||||||
|
<ResizablePanel defaultSize={30} minSize={20}>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">Title</TableHead>
|
||||||
|
<TableHead className="text-xs w-[80px]">
|
||||||
|
Start
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs w-[60px]">
|
||||||
|
Days
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[60px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<TableRow key={task.id}>
|
||||||
|
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
showPhases
|
||||||
|
? "border-l-2 pl-1.5 border-primary"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs py-1.5 text-muted-foreground">
|
||||||
|
{task.startDate.slice(5)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs py-1.5">
|
||||||
|
{task.workdays}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingTask(task)
|
||||||
|
setTaskFormOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPencil className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs w-full justify-start"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingTask(null)
|
||||||
|
setTaskFormOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlus className="size-3 mr-1" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel defaultSize={70} minSize={40}>
|
||||||
|
<div className="h-full overflow-auto p-2">
|
||||||
<GanttChart
|
<GanttChart
|
||||||
tasks={frappeTasks}
|
tasks={frappeTasks}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onDateChange={handleDateChange}
|
onDateChange={handleDateChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
<TaskFormDialog
|
||||||
|
open={taskFormOpen}
|
||||||
|
onOpenChange={setTaskFormOpen}
|
||||||
|
projectId={projectId}
|
||||||
|
editingTask={editingTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react"
|
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getGroupedRowModel,
|
getPaginationRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
@ -17,45 +17,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPencil,
|
||||||
IconLink,
|
|
||||||
IconGripVertical,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconLink,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import { TaskFormDialog } from "./task-form-dialog"
|
|
||||||
import { DependencyDialog } from "./dependency-dialog"
|
|
||||||
import {
|
|
||||||
deleteTask,
|
|
||||||
updateTaskStatus,
|
|
||||||
reorderTasks,
|
|
||||||
} from "@/app/actions/schedule"
|
|
||||||
import { getPhaseColor } from "@/lib/schedule/phase-colors"
|
|
||||||
import type {
|
|
||||||
ScheduleTaskData,
|
|
||||||
TaskDependencyData,
|
|
||||||
TaskStatus,
|
|
||||||
} from "@/lib/schedule/types"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -63,6 +30,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { TaskFormDialog } from "./task-form-dialog"
|
||||||
|
import { DependencyDialog } from "./dependency-dialog"
|
||||||
|
import { deleteTask } from "@/app/actions/schedule"
|
||||||
|
import type {
|
||||||
|
ScheduleTaskData,
|
||||||
|
TaskDependencyData,
|
||||||
|
} from "@/lib/schedule/types"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { format, parseISO } from "date-fns"
|
||||||
|
|
||||||
interface ScheduleListViewProps {
|
interface ScheduleListViewProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
@ -70,49 +47,87 @@ interface ScheduleListViewProps {
|
|||||||
dependencies: TaskDependencyData[]
|
dependencies: TaskDependencyData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions: { value: TaskStatus; label: string }[] = [
|
function StatusDot({ task }: { task: ScheduleTaskData }) {
|
||||||
{ value: "PENDING", label: "Pending" },
|
let color = "bg-gray-400"
|
||||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
if (task.status === "COMPLETE") color = "bg-green-500"
|
||||||
{ value: "COMPLETE", label: "Complete" },
|
else if (task.status === "IN_PROGRESS") color = "bg-blue-500"
|
||||||
{ value: "BLOCKED", label: "Blocked" },
|
else if (task.status === "BLOCKED") color = "bg-red-500"
|
||||||
]
|
else if (task.isCriticalPath) color = "bg-orange-500"
|
||||||
|
return <span className={`inline-block size-2.5 rounded-full ${color}`} />
|
||||||
function SortableRow({
|
|
||||||
row,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
row: { id: string }
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: row.id })
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProgressRing({
|
||||||
|
percent,
|
||||||
|
size = 28,
|
||||||
|
}: {
|
||||||
|
percent: number
|
||||||
|
size?: number
|
||||||
|
}) {
|
||||||
|
const stroke = 3
|
||||||
|
const radius = (size - stroke) / 2
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const offset = circumference - (percent / 100) * circumference
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow ref={setNodeRef} style={style}>
|
<div className="relative inline-flex items-center justify-center">
|
||||||
<TableCell className="w-8">
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
<IconGripVertical
|
<circle
|
||||||
className="size-4 text-muted-foreground cursor-grab"
|
cx={size / 2}
|
||||||
{...attributes}
|
cy={size / 2}
|
||||||
{...listeners}
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
className="text-muted-foreground/20"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
<circle
|
||||||
{children}
|
cx={size / 2}
|
||||||
</TableRow>
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-[9px] font-medium">
|
||||||
|
{percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InitialsAvatar({ name }: { name: string }) {
|
||||||
|
const initials = name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="size-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-medium">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[80px]">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return format(parseISO(dateStr), "MMM d, yyyy")
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ScheduleListView({
|
export function ScheduleListView({
|
||||||
projectId,
|
projectId,
|
||||||
tasks,
|
tasks,
|
||||||
@ -123,30 +138,12 @@ export function ScheduleListView({
|
|||||||
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(null)
|
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(null)
|
||||||
const [depDialogOpen, setDepDialogOpen] = useState(false)
|
const [depDialogOpen, setDepDialogOpen] = useState(false)
|
||||||
const [localTasks, setLocalTasks] = useState(tasks)
|
const [localTasks, setLocalTasks] = useState(tasks)
|
||||||
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalTasks(tasks)
|
setLocalTasks(tasks)
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
|
||||||
async (taskId: string, status: TaskStatus) => {
|
|
||||||
const result = await updateTaskStatus(taskId, status)
|
|
||||||
if (result.success) {
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(result.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (taskId: string) => {
|
async (taskId: string) => {
|
||||||
const result = await deleteTask(taskId)
|
const result = await deleteTask(taskId)
|
||||||
@ -159,48 +156,81 @@ export function ScheduleListView({
|
|||||||
[router]
|
[router]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const columns: ColumnDef<ScheduleTaskData>[] = useMemo(
|
||||||
async (event: DragEndEvent) => {
|
() => [
|
||||||
const { active, over } = event
|
{
|
||||||
if (!over || active.id === over.id) return
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
const oldIndex = localTasks.findIndex((t) => t.id === active.id)
|
<Checkbox
|
||||||
const newIndex = localTasks.findIndex((t) => t.id === over.id)
|
checked={table.getIsAllRowsSelected()}
|
||||||
const reordered = arrayMove(localTasks, oldIndex, newIndex)
|
onCheckedChange={(value) =>
|
||||||
setLocalTasks(reordered)
|
table.toggleAllRowsSelected(!!value)
|
||||||
|
}
|
||||||
const items = reordered.map((t, i) => ({ id: t.id, sortOrder: i }))
|
/>
|
||||||
await reorderTasks(projectId, items)
|
),
|
||||||
router.refresh()
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
size: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "idNum",
|
||||||
|
header: "#",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.sortOrder + 1}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 40,
|
||||||
},
|
},
|
||||||
[localTasks, projectId, router]
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns: ColumnDef<ScheduleTaskData>[] = [
|
|
||||||
{
|
{
|
||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "Task",
|
header: "Title",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
className="text-left font-medium hover:underline"
|
<StatusDot task={row.original} />
|
||||||
onClick={() => {
|
<span className="font-medium text-sm truncate max-w-[200px]">
|
||||||
setEditingTask(row.original)
|
|
||||||
setTaskFormOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.original.title}
|
{row.original.title}
|
||||||
{row.original.isMilestone && (
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">◆</span>
|
</div>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "complete",
|
||||||
|
header: "Complete",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ProgressRing percent={row.original.percentComplete} />
|
||||||
|
),
|
||||||
|
size: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phase",
|
||||||
|
header: "Phase",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[80px] inline-block">
|
||||||
|
{row.original.phase}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "duration",
|
||||||
|
header: "Duration",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs">
|
||||||
|
{row.original.workdays} {row.original.workdays === 1 ? "day" : "days"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 80,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "startDate",
|
accessorKey: "startDate",
|
||||||
header: "Start",
|
header: "Start",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{row.original.startDate}
|
{formatDate(row.original.startDate)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -208,98 +238,66 @@ export function ScheduleListView({
|
|||||||
accessorKey: "endDateCalculated",
|
accessorKey: "endDateCalculated",
|
||||||
header: "End",
|
header: "End",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{row.original.endDateCalculated}
|
{formatDate(row.original.endDateCalculated)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "workdays",
|
id: "assignedTo",
|
||||||
header: "Days",
|
header: "Assigned To",
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm">{row.original.workdays}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "phase",
|
|
||||||
header: "Phase",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const colors = getPhaseColor(row.original.phase)
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary" className={colors.badge}>
|
|
||||||
{row.original.phase}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Select
|
|
||||||
value={row.original.status}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
handleStatusChange(row.original.id, val as TaskStatus)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-[120px] text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "criticalPath",
|
|
||||||
header: "CP",
|
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.isCriticalPath ? (
|
row.original.assignedTo ? (
|
||||||
<Badge variant="destructive" className="text-xs">
|
<InitialsAvatar name={row.original.assignedTo} />
|
||||||
Critical
|
) : (
|
||||||
</Badge>
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
) : null,
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingTask(row.original)
|
||||||
|
setTaskFormOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7"
|
className="size-7"
|
||||||
onClick={() => handleDelete(row.original.id)}
|
onClick={() => handleDelete(row.original.id)}
|
||||||
>
|
>
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
|
size: 80,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
[handleDelete]
|
||||||
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: localTasks,
|
data: localTasks,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: { rowSelection },
|
||||||
|
initialState: { pagination: { pageSize: 25 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingTask(null)
|
|
||||||
setTaskFormOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconPlus className="size-4 mr-1" />
|
|
||||||
Add Task
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -312,16 +310,10 @@ export function ScheduleListView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
<TableHead className="w-8" />
|
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
@ -336,22 +328,18 @@ export function ScheduleListView({
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<SortableContext
|
|
||||||
items={localTasks.map((t) => t.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={columns.length + 1}
|
colSpan={columns.length}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
No tasks yet. Click "Add Task" to get started.
|
No tasks yet. Click "New Schedule Item" to get started.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<SortableRow key={row.id} row={row}>
|
<TableRow key={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@ -360,13 +348,58 @@ export function ScheduleListView({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</SortableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</SortableContext>
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</DndContext>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3 px-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{table.getState().pagination.pageIndex *
|
||||||
|
table.getState().pagination.pageSize +
|
||||||
|
1}
|
||||||
|
-
|
||||||
|
{Math.min(
|
||||||
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
|
table.getState().pagination.pageSize,
|
||||||
|
localTasks.length
|
||||||
|
)}{" "}
|
||||||
|
of {localTasks.length} items
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(table.getState().pagination.pageSize)}
|
||||||
|
onValueChange={(val) => table.setPageSize(Number(val))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[70px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskFormDialog
|
<TaskFormDialog
|
||||||
|
|||||||
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,35 +2,94 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { ScheduleToolbar } from "./schedule-toolbar"
|
||||||
import { ScheduleListView } from "./schedule-list-view"
|
import { ScheduleListView } from "./schedule-list-view"
|
||||||
import { ScheduleGanttView } from "./schedule-gantt-view"
|
import { ScheduleGanttView } from "./schedule-gantt-view"
|
||||||
import type { ScheduleData } from "@/lib/schedule/types"
|
import { ScheduleCalendarView } from "./schedule-calendar-view"
|
||||||
|
import { WorkdayExceptionsView } from "./workday-exceptions-view"
|
||||||
|
import { ScheduleBaselineView } from "./schedule-baseline-view"
|
||||||
|
import { TaskFormDialog } from "./task-form-dialog"
|
||||||
|
import type {
|
||||||
|
ScheduleData,
|
||||||
|
ScheduleBaselineData,
|
||||||
|
} from "@/lib/schedule/types"
|
||||||
|
|
||||||
|
type TopTab = "schedule" | "baseline" | "exceptions"
|
||||||
|
type ScheduleSubTab = "calendar" | "list" | "gantt"
|
||||||
|
|
||||||
interface ScheduleViewProps {
|
interface ScheduleViewProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
projectName: string
|
projectName: string
|
||||||
initialData: ScheduleData
|
initialData: ScheduleData
|
||||||
|
baselines: ScheduleBaselineData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleView({
|
export function ScheduleView({
|
||||||
projectId,
|
projectId,
|
||||||
projectName,
|
projectName,
|
||||||
initialData,
|
initialData,
|
||||||
|
baselines,
|
||||||
}: ScheduleViewProps) {
|
}: ScheduleViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState("list")
|
const [topTab, setTopTab] = useState<TopTab>("schedule")
|
||||||
|
const [subTab, setSubTab] = useState<ScheduleSubTab>("list")
|
||||||
|
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-semibold">{projectName} - Schedule</h1>
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{projectName} - Schedule
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs
|
||||||
|
value={topTab}
|
||||||
|
onValueChange={(v) => setTopTab(v as TopTab)}
|
||||||
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="list">List</TabsTrigger>
|
<TabsTrigger value="schedule">Schedule</TabsTrigger>
|
||||||
<TabsTrigger value="gantt">Gantt</TabsTrigger>
|
<TabsTrigger value="baseline">Baseline</TabsTrigger>
|
||||||
|
<TabsTrigger value="exceptions">
|
||||||
|
Workday Exceptions
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="schedule" className="mt-0">
|
||||||
|
<ScheduleToolbar onNewItem={() => setTaskFormOpen(true)} />
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={subTab}
|
||||||
|
onValueChange={(v) => setSubTab(v as ScheduleSubTab)}
|
||||||
|
>
|
||||||
|
<TabsList className="bg-transparent border-b rounded-none h-auto p-0 gap-4">
|
||||||
|
<TabsTrigger
|
||||||
|
value="calendar"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
|
||||||
|
>
|
||||||
|
Calendar
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="list"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="gantt"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
|
||||||
|
>
|
||||||
|
Gantt
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="calendar" className="mt-4">
|
||||||
|
<ScheduleCalendarView
|
||||||
|
projectId={projectId}
|
||||||
|
tasks={initialData.tasks}
|
||||||
|
exceptions={initialData.exceptions}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-4">
|
<TabsContent value="list" className="mt-4">
|
||||||
<ScheduleListView
|
<ScheduleListView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@ -47,6 +106,30 @@ export function ScheduleView({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="baseline" className="mt-4">
|
||||||
|
<ScheduleBaselineView
|
||||||
|
projectId={projectId}
|
||||||
|
baselines={baselines}
|
||||||
|
currentTasks={initialData.tasks}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="exceptions" className="mt-4">
|
||||||
|
<WorkdayExceptionsView
|
||||||
|
projectId={projectId}
|
||||||
|
exceptions={initialData.exceptions}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<TaskFormDialog
|
||||||
|
open={taskFormOpen}
|
||||||
|
onOpenChange={setTaskFormOpen}
|
||||||
|
projectId={projectId}
|
||||||
|
editingTask={null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form"
|
} from "@/components/ui/form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -53,9 +54,11 @@ const phases: { value: ConstructionPhase; label: string }[] = [
|
|||||||
const taskSchema = z.object({
|
const taskSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
startDate: z.string().min(1, "Start date is required"),
|
startDate: z.string().min(1, "Start date is required"),
|
||||||
workdays: z.coerce.number().min(1, "Must be at least 1 day"),
|
workdays: z.number().min(1, "Must be at least 1 day"),
|
||||||
phase: z.string().min(1, "Phase is required"),
|
phase: z.string().min(1, "Phase is required"),
|
||||||
isMilestone: z.boolean(),
|
isMilestone: z.boolean(),
|
||||||
|
percentComplete: z.number().min(0).max(100),
|
||||||
|
assignedTo: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type TaskFormValues = z.infer<typeof taskSchema>
|
type TaskFormValues = z.infer<typeof taskSchema>
|
||||||
@ -84,6 +87,8 @@ export function TaskFormDialog({
|
|||||||
workdays: 5,
|
workdays: 5,
|
||||||
phase: "preconstruction",
|
phase: "preconstruction",
|
||||||
isMilestone: false,
|
isMilestone: false,
|
||||||
|
percentComplete: 0,
|
||||||
|
assignedTo: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -95,6 +100,8 @@ export function TaskFormDialog({
|
|||||||
workdays: editingTask.workdays,
|
workdays: editingTask.workdays,
|
||||||
phase: editingTask.phase,
|
phase: editingTask.phase,
|
||||||
isMilestone: editingTask.isMilestone,
|
isMilestone: editingTask.isMilestone,
|
||||||
|
percentComplete: editingTask.percentComplete,
|
||||||
|
assignedTo: editingTask.assignedTo ?? "",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@ -103,6 +110,8 @@ export function TaskFormDialog({
|
|||||||
workdays: 5,
|
workdays: 5,
|
||||||
phase: "preconstruction",
|
phase: "preconstruction",
|
||||||
isMilestone: false,
|
isMilestone: false,
|
||||||
|
percentComplete: 0,
|
||||||
|
assignedTo: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [editingTask, form])
|
}, [editingTask, form])
|
||||||
@ -118,9 +127,15 @@ export function TaskFormDialog({
|
|||||||
async function onSubmit(values: TaskFormValues) {
|
async function onSubmit(values: TaskFormValues) {
|
||||||
let result
|
let result
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
result = await updateTask(editingTask.id, values)
|
result = await updateTask(editingTask.id, {
|
||||||
|
...values,
|
||||||
|
assignedTo: values.assignedTo || null,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
result = await createTask(projectId, values)
|
result = await createTask(projectId, {
|
||||||
|
...values,
|
||||||
|
assignedTo: values.assignedTo || undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@ -178,7 +193,17 @@ export function TaskFormDialog({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Workdays</FormLabel>
|
<FormLabel>Workdays</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" min={1} {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
ref={field.ref}
|
||||||
|
name={field.name}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -220,6 +245,42 @@ export function TaskFormDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="percentComplete"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Complete: {field.value}%
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={[field.value]}
|
||||||
|
onValueChange={([val]) => field.onChange(val)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assignedTo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Assigned To</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Person name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="isMilestone"
|
name="isMilestone"
|
||||||
|
|||||||
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 * as React from "react"
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
import { GripVerticalIcon } from "lucide-react"
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
import { Group, Panel, Separator } from "react-resizable-panels"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function ResizablePanelGroup({
|
function ResizablePanelGroup({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
}: React.ComponentProps<typeof Group>) {
|
||||||
return (
|
return (
|
||||||
<ResizablePrimitive.PanelGroup
|
<Group
|
||||||
data-slot="resizable-panel-group"
|
data-slot="resizable-panel-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
@ -24,19 +24,19 @@ function ResizablePanelGroup({
|
|||||||
|
|
||||||
function ResizablePanel({
|
function ResizablePanel({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
}: React.ComponentProps<typeof Panel>) {
|
||||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
return <Panel data-slot="resizable-panel" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResizableHandle({
|
function ResizableHandle({
|
||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<Separator
|
||||||
data-slot="resizable-handle"
|
data-slot="resizable-handle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
@ -49,7 +49,7 @@ function ResizableHandle({
|
|||||||
<GripVerticalIcon className="size-2.5" />
|
<GripVerticalIcon className="size-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</Separator>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export const scheduleTasks = sqliteTable("schedule_tasks", {
|
|||||||
isMilestone: integer("is_milestone", { mode: "boolean" })
|
isMilestone: integer("is_milestone", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
percentComplete: integer("percent_complete").notNull().default(0),
|
||||||
|
assignedTo: text("assigned_to"),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
updatedAt: text("updated_at").notNull(),
|
updatedAt: text("updated_at").notNull(),
|
||||||
@ -44,8 +46,38 @@ export const taskDependencies = sqliteTable("task_dependencies", {
|
|||||||
lagDays: integer("lag_days").notNull().default(0),
|
lagDays: integer("lag_days").notNull().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const workdayExceptions = sqliteTable("workday_exceptions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
startDate: text("start_date").notNull(),
|
||||||
|
endDate: text("end_date").notNull(),
|
||||||
|
type: text("type").notNull().default("non_working"),
|
||||||
|
category: text("category").notNull().default("company_holiday"),
|
||||||
|
recurrence: text("recurrence").notNull().default("one_time"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const scheduleBaselines = sqliteTable("schedule_baselines", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
snapshotData: text("snapshot_data").notNull(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Project = typeof projects.$inferSelect
|
export type Project = typeof projects.$inferSelect
|
||||||
export type ScheduleTask = typeof scheduleTasks.$inferSelect
|
export type ScheduleTask = typeof scheduleTasks.$inferSelect
|
||||||
export type NewScheduleTask = typeof scheduleTasks.$inferInsert
|
export type NewScheduleTask = typeof scheduleTasks.$inferInsert
|
||||||
export type TaskDependency = typeof taskDependencies.$inferSelect
|
export type TaskDependency = typeof taskDependencies.$inferSelect
|
||||||
export type NewTaskDependency = typeof taskDependencies.$inferInsert
|
export type NewTaskDependency = typeof taskDependencies.$inferInsert
|
||||||
|
export type WorkdayException = typeof workdayExceptions.$inferSelect
|
||||||
|
export type NewWorkdayException = typeof workdayExceptions.$inferInsert
|
||||||
|
export type ScheduleBaseline = typeof scheduleBaselines.$inferSelect
|
||||||
|
export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert
|
||||||
|
|||||||
@ -1,22 +1,41 @@
|
|||||||
import { addDays, isWeekend, parseISO, format } from "date-fns"
|
import { addDays, isWeekend, parseISO, format, isWithinInterval } from "date-fns"
|
||||||
|
import type { WorkdayExceptionData } from "./types"
|
||||||
|
|
||||||
|
function isExceptionDay(
|
||||||
|
date: Date,
|
||||||
|
exceptions: WorkdayExceptionData[]
|
||||||
|
): boolean {
|
||||||
|
return exceptions.some((ex) => {
|
||||||
|
const start = parseISO(ex.startDate)
|
||||||
|
const end = parseISO(ex.endDate)
|
||||||
|
return isWithinInterval(date, { start, end })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonWorkday(
|
||||||
|
date: Date,
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
|
): boolean {
|
||||||
|
return isWeekend(date) || isExceptionDay(date, exceptions)
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateEndDate(
|
export function calculateEndDate(
|
||||||
startDate: string,
|
startDate: string,
|
||||||
workdays: number
|
workdays: number,
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
): string {
|
): string {
|
||||||
if (workdays <= 0) return startDate
|
if (workdays <= 0) return startDate
|
||||||
|
|
||||||
let current = parseISO(startDate)
|
let current = parseISO(startDate)
|
||||||
let remaining = workdays
|
let remaining = workdays
|
||||||
|
|
||||||
// start date counts as day 1 if it's a business day
|
if (!isNonWorkday(current, exceptions)) {
|
||||||
if (!isWeekend(current)) {
|
|
||||||
remaining--
|
remaining--
|
||||||
}
|
}
|
||||||
|
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
current = addDays(current, 1)
|
current = addDays(current, 1)
|
||||||
if (!isWeekend(current)) {
|
if (!isNonWorkday(current, exceptions)) {
|
||||||
remaining--
|
remaining--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -26,14 +45,15 @@ export function calculateEndDate(
|
|||||||
|
|
||||||
export function countBusinessDays(
|
export function countBusinessDays(
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string
|
endDate: string,
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
): number {
|
): number {
|
||||||
let current = parseISO(startDate)
|
let current = parseISO(startDate)
|
||||||
const end = parseISO(endDate)
|
const end = parseISO(endDate)
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
while (current <= end) {
|
while (current <= end) {
|
||||||
if (!isWeekend(current)) {
|
if (!isNonWorkday(current, exceptions)) {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
current = addDays(current, 1)
|
current = addDays(current, 1)
|
||||||
@ -44,7 +64,8 @@ export function countBusinessDays(
|
|||||||
|
|
||||||
export function addBusinessDays(
|
export function addBusinessDays(
|
||||||
date: string,
|
date: string,
|
||||||
days: number
|
days: number,
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
): string {
|
): string {
|
||||||
let current = parseISO(date)
|
let current = parseISO(date)
|
||||||
let remaining = Math.abs(days)
|
let remaining = Math.abs(days)
|
||||||
@ -52,7 +73,7 @@ export function addBusinessDays(
|
|||||||
|
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
current = addDays(current, direction)
|
current = addDays(current, direction)
|
||||||
if (!isWeekend(current)) {
|
if (!isNonWorkday(current, exceptions)) {
|
||||||
remaining--
|
remaining--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import type { ScheduleTaskData, TaskDependencyData } from "./types"
|
import type {
|
||||||
|
ScheduleTaskData,
|
||||||
|
TaskDependencyData,
|
||||||
|
WorkdayExceptionData,
|
||||||
|
} from "./types"
|
||||||
import { calculateEndDate, addBusinessDays } from "./business-days"
|
import { calculateEndDate, addBusinessDays } from "./business-days"
|
||||||
|
|
||||||
interface PropagationResult {
|
interface PropagationResult {
|
||||||
@ -8,7 +12,8 @@ interface PropagationResult {
|
|||||||
export function propagateDates(
|
export function propagateDates(
|
||||||
changedTaskId: string,
|
changedTaskId: string,
|
||||||
tasks: ScheduleTaskData[],
|
tasks: ScheduleTaskData[],
|
||||||
dependencies: TaskDependencyData[]
|
dependencies: TaskDependencyData[],
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
): PropagationResult {
|
): PropagationResult {
|
||||||
const taskMap = new Map(tasks.map((t) => [t.id, { ...t }]))
|
const taskMap = new Map(tasks.map((t) => [t.id, { ...t }]))
|
||||||
const updates = new Map<string, { startDate: string; endDateCalculated: string }>()
|
const updates = new Map<string, { startDate: string; endDateCalculated: string }>()
|
||||||
@ -43,9 +48,10 @@ export function propagateDates(
|
|||||||
// successor starts after predecessor ends + lag
|
// successor starts after predecessor ends + lag
|
||||||
const newStart = addBusinessDays(
|
const newStart = addBusinessDays(
|
||||||
current.endDateCalculated,
|
current.endDateCalculated,
|
||||||
1 + dep.lagDays
|
1 + dep.lagDays,
|
||||||
|
exceptions
|
||||||
)
|
)
|
||||||
const newEnd = calculateEndDate(newStart, successor.workdays)
|
const newEnd = calculateEndDate(newStart, successor.workdays, exceptions)
|
||||||
|
|
||||||
if (newStart !== successor.startDate || newEnd !== successor.endDateCalculated) {
|
if (newStart !== successor.startDate || newEnd !== successor.endDateCalculated) {
|
||||||
successor.startDate = newStart
|
successor.startDate = newStart
|
||||||
|
|||||||
@ -17,6 +17,15 @@ export type ConstructionPhase =
|
|||||||
| "landscaping"
|
| "landscaping"
|
||||||
| "closeout"
|
| "closeout"
|
||||||
|
|
||||||
|
export type ExceptionCategory =
|
||||||
|
| "national_holiday"
|
||||||
|
| "state_holiday"
|
||||||
|
| "vacation_day"
|
||||||
|
| "company_holiday"
|
||||||
|
| "weather_day"
|
||||||
|
|
||||||
|
export type ExceptionRecurrence = "one_time" | "yearly"
|
||||||
|
|
||||||
export interface ScheduleTaskData {
|
export interface ScheduleTaskData {
|
||||||
id: string
|
id: string
|
||||||
projectId: string
|
projectId: string
|
||||||
@ -28,6 +37,8 @@ export interface ScheduleTaskData {
|
|||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
isCriticalPath: boolean
|
isCriticalPath: boolean
|
||||||
isMilestone: boolean
|
isMilestone: boolean
|
||||||
|
percentComplete: number
|
||||||
|
assignedTo: string | null
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@ -41,7 +52,30 @@ export interface TaskDependencyData {
|
|||||||
lagDays: number
|
lagDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkdayExceptionData {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
title: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
type: string
|
||||||
|
category: ExceptionCategory
|
||||||
|
recurrence: ExceptionRecurrence
|
||||||
|
notes: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleBaselineData {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
snapshotData: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduleData {
|
export interface ScheduleData {
|
||||||
tasks: ScheduleTaskData[]
|
tasks: ScheduleTaskData[]
|
||||||
dependencies: TaskDependencyData[]
|
dependencies: TaskDependencyData[]
|
||||||
|
exceptions: WorkdayExceptionData[]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user