From aa6230c9d4c046fb44ee419f06ddc7ddc3c56279 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Fri, 23 Jan 2026 20:14:09 -0700 Subject: [PATCH] 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. --- drizzle/0001_gorgeous_sebastian_shaw.sql | 26 + drizzle/meta/0001_snapshot.json | 420 +++++++++++++ drizzle/meta/_journal.json | 7 + src/app/actions/baselines.ts | 91 +++ src/app/actions/schedule.ts | 69 ++- src/app/actions/workday-exceptions.ts | 146 +++++ .../dashboard/projects/[id]/schedule/page.tsx | 44 +- .../schedule/schedule-baseline-view.tsx | 274 +++++++++ .../schedule/schedule-calendar-view.tsx | 233 +++++++ .../schedule/schedule-gantt-view.tsx | 178 +++++- .../schedule/schedule-list-view.tsx | 579 +++++++++--------- src/components/schedule/schedule-toolbar.tsx | 70 +++ src/components/schedule/schedule-view.tsx | 111 +++- src/components/schedule/task-form-dialog.tsx | 69 ++- .../workday-exception-form-dialog.tsx | 294 +++++++++ .../schedule/workday-exceptions-view.tsx | 174 ++++++ src/components/ui/resizable.tsx | 16 +- src/db/schema.ts | 32 + src/lib/schedule/business-days.ts | 39 +- src/lib/schedule/propagate-dates.ts | 14 +- src/lib/schedule/types.ts | 34 + 21 files changed, 2576 insertions(+), 344 deletions(-) create mode 100755 drizzle/0001_gorgeous_sebastian_shaw.sql create mode 100755 drizzle/meta/0001_snapshot.json create mode 100755 src/app/actions/baselines.ts create mode 100755 src/app/actions/workday-exceptions.ts create mode 100755 src/components/schedule/schedule-baseline-view.tsx create mode 100755 src/components/schedule/schedule-calendar-view.tsx create mode 100755 src/components/schedule/schedule-toolbar.tsx create mode 100755 src/components/schedule/workday-exception-form-dialog.tsx create mode 100755 src/components/schedule/workday-exceptions-view.tsx diff --git a/drizzle/0001_gorgeous_sebastian_shaw.sql b/drizzle/0001_gorgeous_sebastian_shaw.sql new file mode 100755 index 0000000..bb5665e --- /dev/null +++ b/drizzle/0001_gorgeous_sebastian_shaw.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100755 index 0000000..2d47141 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c850859..a0cd5c1 100755 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769219785552, "tag": "0000_nice_sabra", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1769222679525, + "tag": "0001_gorgeous_sebastian_shaw", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/baselines.ts b/src/app/actions/baselines.ts new file mode 100755 index 0000000..b214e30 --- /dev/null +++ b/src/app/actions/baselines.ts @@ -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 { + 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" } + } +} diff --git a/src/app/actions/schedule.ts b/src/app/actions/schedule.ts index 4a0e9bc..4f8fc3f 100755 --- a/src/app/actions/schedule.ts +++ b/src/app/actions/schedule.ts @@ -2,7 +2,12 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" -import { scheduleTasks, taskDependencies, projects } from "@/db/schema" +import { + scheduleTasks, + taskDependencies, + workdayExceptions, + projects, +} from "@/db/schema" import { eq, asc } from "drizzle-orm" import { revalidatePath } from "next/cache" import { calculateEndDate } from "@/lib/schedule/business-days" @@ -12,9 +17,28 @@ import { propagateDates } from "@/lib/schedule/propagate-dates" import type { TaskStatus, DependencyType, + ExceptionCategory, + ExceptionRecurrence, ScheduleData, + WorkdayExceptionData, } from "@/lib/schedule/types" +async function fetchExceptions( + db: ReturnType, + projectId: string +): Promise { + const rows = await db + .select() + .from(workdayExceptions) + .where(eq(workdayExceptions.projectId, projectId)) + + return rows.map((r) => ({ + ...r, + category: r.category as ExceptionCategory, + recurrence: r.recurrence as ExceptionRecurrence, + })) +} + export async function getSchedule( projectId: string ): Promise { @@ -28,8 +52,8 @@ export async function getSchedule( .orderBy(asc(scheduleTasks.sortOrder)) const deps = await db.select().from(taskDependencies) + const exceptions = await fetchExceptions(db, projectId) - // filter deps to only include tasks in this project const taskIds = new Set(tasks.map((t) => t.id)) const projectDeps = deps.filter( (d) => taskIds.has(d.predecessorId) && taskIds.has(d.successorId) @@ -45,6 +69,7 @@ export async function getSchedule( ...d, type: d.type as DependencyType, })), + exceptions, } } @@ -56,16 +81,20 @@ export async function createTask( workdays: number phase: string isMilestone?: boolean + percentComplete?: number + assignedTo?: string } ): Promise<{ success: boolean; error?: string }> { try { const { env } = await getCloudflareContext() const db = getDb(env.DB) - const endDate = calculateEndDate(data.startDate, data.workdays) + const exceptions = await fetchExceptions(db, projectId) + const endDate = calculateEndDate( + data.startDate, data.workdays, exceptions + ) const now = new Date().toISOString() - // get next sort order const existing = await db .select({ sortOrder: scheduleTasks.sortOrder }) .from(scheduleTasks) @@ -88,6 +117,8 @@ export async function createTask( status: "PENDING", isCriticalPath: false, isMilestone: data.isMilestone ?? false, + percentComplete: data.percentComplete ?? 0, + assignedTo: data.assignedTo ?? null, sortOrder: nextOrder, createdAt: now, updatedAt: now, @@ -110,6 +141,8 @@ export async function updateTask( workdays?: number phase?: string isMilestone?: boolean + percentComplete?: number + assignedTo?: string | null } ): Promise<{ success: boolean; error?: string }> { try { @@ -124,9 +157,10 @@ export async function updateTask( if (!task) return { success: false, error: "Task not found" } + const exceptions = await fetchExceptions(db, task.projectId) const startDate = data.startDate ?? task.startDate const workdays = data.workdays ?? task.workdays - const endDate = calculateEndDate(startDate, workdays) + const endDate = calculateEndDate(startDate, workdays, exceptions) await db .update(scheduleTasks) @@ -136,18 +170,34 @@ export async function updateTask( workdays, endDateCalculated: endDate, ...(data.phase && { phase: data.phase }), - ...(data.isMilestone !== undefined && { isMilestone: data.isMilestone }), + ...(data.isMilestone !== undefined && { + isMilestone: data.isMilestone, + }), + ...(data.percentComplete !== undefined && { + percentComplete: data.percentComplete, + }), + ...(data.assignedTo !== undefined && { + assignedTo: data.assignedTo, + }), updatedAt: new Date().toISOString(), }) .where(eq(scheduleTasks.id, taskId)) // propagate date changes to downstream tasks const schedule = await getSchedule(task.projectId) - const updatedTask = { ...task, startDate, workdays, endDateCalculated: endDate } + const updatedTask = { + ...task, + status: task.status as TaskStatus, + startDate, + workdays, + endDateCalculated: endDate, + } const allTasks = schedule.tasks.map((t) => t.id === taskId ? updatedTask : t ) - const { updatedTasks } = propagateDates(taskId, allTasks, schedule.dependencies) + const { updatedTasks } = propagateDates( + taskId, allTasks, schedule.dependencies, exceptions + ) for (const [id, dates] of updatedTasks) { await db @@ -248,7 +298,8 @@ export async function createDependency(data: { const { updatedTasks } = propagateDates( data.predecessorId, updatedSchedule.tasks, - updatedSchedule.dependencies + updatedSchedule.dependencies, + updatedSchedule.exceptions ) for (const [id, dates] of updatedTasks) { diff --git a/src/app/actions/workday-exceptions.ts b/src/app/actions/workday-exceptions.ts new file mode 100755 index 0000000..4b8d766 --- /dev/null +++ b/src/app/actions/workday-exceptions.ts @@ -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 { + 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" } + } +} diff --git a/src/app/dashboard/projects/[id]/schedule/page.tsx b/src/app/dashboard/projects/[id]/schedule/page.tsx index 3a16fc5..e25503c 100755 --- a/src/app/dashboard/projects/[id]/schedule/page.tsx +++ b/src/app/dashboard/projects/[id]/schedule/page.tsx @@ -4,7 +4,15 @@ import { projects } from "@/db/schema" import { eq } from "drizzle-orm" import { notFound } from "next/navigation" import { getSchedule } from "@/app/actions/schedule" +import { getBaselines } from "@/app/actions/baselines" import { ScheduleView } from "@/components/schedule/schedule-view" +import type { ScheduleData, ScheduleBaselineData } from "@/lib/schedule/types" + +const emptySchedule: ScheduleData = { + tasks: [], + dependencies: [], + exceptions: [], +} export default async function SchedulePage({ params, @@ -12,25 +20,41 @@ export default async function SchedulePage({ params: Promise<{ id: string }> }) { const { id } = await params - const { env } = await getCloudflareContext() - const db = getDb(env.DB) - const [project] = await db - .select() - .from(projects) - .where(eq(projects.id, id)) - .limit(1) + let projectName = "Project" + let schedule: ScheduleData = emptySchedule + let baselines: ScheduleBaselineData[] = [] - if (!project) notFound() + try { + const { env } = await getCloudflareContext() + if (!env?.DB) throw new Error("D1 not available") - const schedule = await getSchedule(id) + const db = getDb(env.DB) + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, id)) + .limit(1) + + if (!project) notFound() + + projectName = project.name + ;[schedule, baselines] = await Promise.all([ + getSchedule(id), + getBaselines(id), + ]) + } catch (e: any) { + if (e?.digest === "NEXT_NOT_FOUND") throw e + console.warn("D1 unavailable in dev mode, using empty data") + } return (
) diff --git a/src/components/schedule/schedule-baseline-view.tsx b/src/components/schedule/schedule-baseline-view.tsx new file mode 100755 index 0000000..d165dd6 --- /dev/null +++ b/src/components/schedule/schedule-baseline-view.tsx @@ -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(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 ( +
+
+ setBaselineName(e.target.value)} + className="max-w-[250px]" + /> + +
+ + {baselines.length > 0 && ( +
+

Saved Baselines

+
+ + + + Name + Created + + + + + {baselines.map((b) => ( + + + {b.name} + + + {formatDate(b.createdAt.split("T")[0])} + + +
+ + +
+
+
+ ))} +
+
+
+
+ )} + + {comparison && ( +
+

+ Comparison: Current vs Baseline +

+
+ + + + Task + Baseline Start + Baseline End + Current Start + Current End + Variance + + + + {comparison.map((row, i) => ( + + + {row.title} + + + {row.originalStart === "-" + ? "-" + : formatDate(row.originalStart)} + + + {row.originalEnd === "-" + ? "-" + : formatDate(row.originalEnd)} + + + {formatDate(row.currentStart)} + + + {formatDate(row.currentEnd)} + + + + {row.variance} + + + + ))} + +
+
+
+ )} +
+ ) +} diff --git a/src/components/schedule/schedule-calendar-view.tsx b/src/components/schedule/schedule-calendar-view.tsx new file mode 100755 index 0000000..3d79de6 --- /dev/null +++ b/src/components/schedule/schedule-calendar-view.tsx @@ -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>( + 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() + 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 ( +
+
+
+ + + +

+ {format(currentDate, "MMMM, yyyy")} +

+
+ +
+ +
+
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( + (day) => ( +
+ {day} +
+ ) + )} +
+ +
+ {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 ( +
+
+ + {format(day, "d")} + + {isNonWork && ( + + Non-workday + + )} +
+
+ {visibleTasks.map((task) => ( +
+ {task.title} +
+ ))} + {!expanded && overflow > 0 && ( + + )} + {expanded && dayTasks.length > MAX_VISIBLE_TASKS && ( + + )} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/schedule/schedule-gantt-view.tsx b/src/components/schedule/schedule-gantt-view.tsx index ee05359..9d8b64a 100755 --- a/src/components/schedule/schedule-gantt-view.tsx +++ b/src/components/schedule/schedule-gantt-view.tsx @@ -1,9 +1,24 @@ "use client" import { useState, useCallback } from "react" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { IconPencil, IconPlus } from "@tabler/icons-react" import { GanttChart } from "./gantt-chart" -import "./gantt.css" +import { TaskFormDialog } from "./task-form-dialog" import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform" import { updateTask } from "@/app/actions/schedule" import { countBusinessDays } from "@/lib/schedule/business-days" @@ -31,8 +46,18 @@ export function ScheduleGanttView({ }: ScheduleGanttViewProps) { const router = useRouter() const [viewMode, setViewMode] = useState("Week") + const [showPhases, setShowPhases] = useState(false) + const [showCriticalPath, setShowCriticalPath] = useState(false) + const [taskFormOpen, setTaskFormOpen] = useState(false) + const [editingTask, setEditingTask] = useState( + null + ) - const frappeTasks = transformToFrappeTasks(tasks, dependencies) + const filteredTasks = showCriticalPath + ? tasks.filter((t) => t.isCriticalPath) + : tasks + + const frappeTasks = transformToFrappeTasks(filteredTasks, dependencies) const handleDateChange = useCallback( async (task: FrappeTask, start: Date, end: Date) => { @@ -54,25 +79,152 @@ export function ScheduleGanttView({ [router] ) + const scrollToToday = () => { + const todayEl = document.querySelector(".gantt-container .today-highlight") + if (todayEl) { + todayEl.scrollIntoView({ behavior: "smooth", inline: "center" }) + } + } + return (
-
- {(["Day", "Week", "Month"] as ViewMode[]).map((mode) => ( +
+
+ {(["Day", "Week", "Month"] as ViewMode[]).map((mode) => ( + + ))} - ))} +
+
+
+ + + Phases + +
+
+ + + Critical Path + +
+
- + +
+ + + + Title + + Start + + + Days + + + + + + {filteredTasks.map((task) => ( + + + + {task.title} + + + + {task.startDate.slice(5)} + + + {task.workdays} + + + + + + ))} + + + + + + +
+
+
+ + + + +
+ +
+
+ + +
) diff --git a/src/components/schedule/schedule-list-view.tsx b/src/components/schedule/schedule-list-view.tsx index b30ece0..67fdf5a 100755 --- a/src/components/schedule/schedule-list-view.tsx +++ b/src/components/schedule/schedule-list-view.tsx @@ -1,10 +1,10 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback, useEffect, useMemo } from "react" import { useReactTable, getCoreRowModel, - getGroupedRowModel, + getPaginationRowModel, flexRender, type ColumnDef, } from "@tanstack/react-table" @@ -17,45 +17,12 @@ import { TableRow, } from "@/components/ui/table" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" import { - IconPlus, - IconLink, - IconGripVertical, + IconPencil, IconTrash, + IconLink, } from "@tabler/icons-react" -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core" -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { TaskFormDialog } from "./task-form-dialog" -import { DependencyDialog } from "./dependency-dialog" -import { - deleteTask, - updateTaskStatus, - reorderTasks, -} from "@/app/actions/schedule" -import { getPhaseColor } from "@/lib/schedule/phase-colors" -import type { - ScheduleTaskData, - TaskDependencyData, - TaskStatus, -} from "@/lib/schedule/types" -import { useRouter } from "next/navigation" -import { toast } from "sonner" import { Select, SelectContent, @@ -63,6 +30,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { TaskFormDialog } from "./task-form-dialog" +import { DependencyDialog } from "./dependency-dialog" +import { deleteTask } from "@/app/actions/schedule" +import type { + ScheduleTaskData, + TaskDependencyData, +} from "@/lib/schedule/types" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { format, parseISO } from "date-fns" interface ScheduleListViewProps { projectId: string @@ -70,49 +47,87 @@ interface ScheduleListViewProps { dependencies: TaskDependencyData[] } -const statusOptions: { value: TaskStatus; label: string }[] = [ - { value: "PENDING", label: "Pending" }, - { value: "IN_PROGRESS", label: "In Progress" }, - { value: "COMPLETE", label: "Complete" }, - { value: "BLOCKED", label: "Blocked" }, -] +function StatusDot({ task }: { task: ScheduleTaskData }) { + let color = "bg-gray-400" + if (task.status === "COMPLETE") color = "bg-green-500" + else if (task.status === "IN_PROGRESS") color = "bg-blue-500" + else if (task.status === "BLOCKED") color = "bg-red-500" + else if (task.isCriticalPath) color = "bg-orange-500" + return +} -function SortableRow({ - row, - children, +function ProgressRing({ + percent, + size = 28, }: { - row: { id: string } - children: React.ReactNode + percent: number + size?: number }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: row.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } + const stroke = 3 + const radius = (size - stroke) / 2 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference return ( - - - + + - - {children} - + + + + {percent}% + +
) } +function InitialsAvatar({ name }: { name: string }) { + const initials = name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + .toUpperCase() + + return ( +
+
+ {initials} +
+ + {name} + +
+ ) +} + +function formatDate(dateStr: string): string { + try { + return format(parseISO(dateStr), "MMM d, yyyy") + } catch { + return dateStr + } +} + export function ScheduleListView({ projectId, tasks, @@ -123,30 +138,12 @@ export function ScheduleListView({ const [editingTask, setEditingTask] = useState(null) const [depDialogOpen, setDepDialogOpen] = useState(false) const [localTasks, setLocalTasks] = useState(tasks) + const [rowSelection, setRowSelection] = useState>({}) useEffect(() => { setLocalTasks(tasks) }, [tasks]) - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ) - - const handleStatusChange = useCallback( - async (taskId: string, status: TaskStatus) => { - const result = await updateTaskStatus(taskId, status) - if (result.success) { - router.refresh() - } else { - toast.error(result.error) - } - }, - [router] - ) - const handleDelete = useCallback( async (taskId: string) => { const result = await deleteTask(taskId) @@ -159,147 +156,148 @@ export function ScheduleListView({ [router] ) - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - const oldIndex = localTasks.findIndex((t) => t.id === active.id) - const newIndex = localTasks.findIndex((t) => t.id === over.id) - const reordered = arrayMove(localTasks, oldIndex, newIndex) - setLocalTasks(reordered) - - const items = reordered.map((t, i) => ({ id: t.id, sortOrder: i })) - await reorderTasks(projectId, items) - router.refresh() - }, - [localTasks, projectId, router] - ) - - const columns: ColumnDef[] = [ - { - accessorKey: "title", - header: "Task", - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "startDate", - header: "Start", - cell: ({ row }) => ( - - {row.original.startDate} - - ), - }, - { - accessorKey: "endDateCalculated", - header: "End", - cell: ({ row }) => ( - - {row.original.endDateCalculated} - - ), - }, - { - accessorKey: "workdays", - header: "Days", - cell: ({ row }) => ( - {row.original.workdays} - ), - }, - { - accessorKey: "phase", - header: "Phase", - cell: ({ row }) => { - const colors = getPhaseColor(row.original.phase) - return ( - - {row.original.phase} - - ) + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllRowsSelected(!!value) + } + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + /> + ), + size: 32, }, - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => ( - - ), - }, - { - id: "criticalPath", - header: "CP", - cell: ({ row }) => - row.original.isCriticalPath ? ( - - Critical - - ) : null, - }, - { - id: "actions", - cell: ({ row }) => ( - - ), - }, - ] + { + id: "idNum", + header: "#", + cell: ({ row }) => ( + + {row.original.sortOrder + 1} + + ), + size: 40, + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => ( +
+ + + {row.original.title} + +
+ ), + }, + { + id: "complete", + header: "Complete", + cell: ({ row }) => ( + + ), + size: 70, + }, + { + accessorKey: "phase", + header: "Phase", + cell: ({ row }) => ( + + {row.original.phase} + + ), + }, + { + id: "duration", + header: "Duration", + cell: ({ row }) => ( + + {row.original.workdays} {row.original.workdays === 1 ? "day" : "days"} + + ), + size: 80, + }, + { + accessorKey: "startDate", + header: "Start", + cell: ({ row }) => ( + + {formatDate(row.original.startDate)} + + ), + }, + { + accessorKey: "endDateCalculated", + header: "End", + cell: ({ row }) => ( + + {formatDate(row.original.endDateCalculated)} + + ), + }, + { + id: "assignedTo", + header: "Assigned To", + cell: ({ row }) => + row.original.assignedTo ? ( + + ) : ( + - + ), + }, + { + id: "actions", + cell: ({ row }) => ( +
+ + +
+ ), + size: 80, + }, + ], + [handleDelete] + ) const table = useReactTable({ data: localTasks, columns, getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), getRowId: (row) => row.id, + onRowSelectionChange: setRowSelection, + state: { rowSelection }, + initialState: { pagination: { pageSize: 25 } }, }) return (
- + +
void +} + +export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) { + const [offlineMode, setOfflineMode] = useState(false) + + return ( +
+
+ + +
+ + + Schedule Offline + +
+ + + + + + Export Schedule + Import Schedule + Print + + + +
+ +
+ ) +} diff --git a/src/components/schedule/schedule-view.tsx b/src/components/schedule/schedule-view.tsx index 357e43e..4603cb7 100755 --- a/src/components/schedule/schedule-view.tsx +++ b/src/components/schedule/schedule-view.tsx @@ -2,51 +2,134 @@ import { useState } from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScheduleToolbar } from "./schedule-toolbar" import { ScheduleListView } from "./schedule-list-view" import { ScheduleGanttView } from "./schedule-gantt-view" -import type { ScheduleData } from "@/lib/schedule/types" +import { ScheduleCalendarView } from "./schedule-calendar-view" +import { WorkdayExceptionsView } from "./workday-exceptions-view" +import { ScheduleBaselineView } from "./schedule-baseline-view" +import { TaskFormDialog } from "./task-form-dialog" +import type { + ScheduleData, + ScheduleBaselineData, +} from "@/lib/schedule/types" + +type TopTab = "schedule" | "baseline" | "exceptions" +type ScheduleSubTab = "calendar" | "list" | "gantt" interface ScheduleViewProps { projectId: string projectName: string initialData: ScheduleData + baselines: ScheduleBaselineData[] } export function ScheduleView({ projectId, projectName, initialData, + baselines, }: ScheduleViewProps) { - const [activeTab, setActiveTab] = useState("list") + const [topTab, setTopTab] = useState("schedule") + const [subTab, setSubTab] = useState("list") + const [taskFormOpen, setTaskFormOpen] = useState(false) return (
-

{projectName} - Schedule

+

+ {projectName} - Schedule +

- + setTopTab(v as TopTab)} + > - List - Gantt + Schedule + Baseline + + Workday Exceptions + - - + setTaskFormOpen(true)} /> + + setSubTab(v as ScheduleSubTab)} + > + + + Calendar + + + List + + + Gantt + + + + + + + + + + + + + + + + + + + - - + + +
) } diff --git a/src/components/schedule/task-form-dialog.tsx b/src/components/schedule/task-form-dialog.tsx index 096369d..1979eeb 100755 --- a/src/components/schedule/task-form-dialog.tsx +++ b/src/components/schedule/task-form-dialog.tsx @@ -19,6 +19,7 @@ import { FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { Slider } from "@/components/ui/slider" import { Select, SelectContent, @@ -53,9 +54,11 @@ const phases: { value: ConstructionPhase; label: string }[] = [ const taskSchema = z.object({ title: z.string().min(1, "Title is required"), startDate: z.string().min(1, "Start date is required"), - workdays: z.coerce.number().min(1, "Must be at least 1 day"), + workdays: z.number().min(1, "Must be at least 1 day"), phase: z.string().min(1, "Phase is required"), isMilestone: z.boolean(), + percentComplete: z.number().min(0).max(100), + assignedTo: z.string(), }) type TaskFormValues = z.infer @@ -84,6 +87,8 @@ export function TaskFormDialog({ workdays: 5, phase: "preconstruction", isMilestone: false, + percentComplete: 0, + assignedTo: "", }, }) @@ -95,6 +100,8 @@ export function TaskFormDialog({ workdays: editingTask.workdays, phase: editingTask.phase, isMilestone: editingTask.isMilestone, + percentComplete: editingTask.percentComplete, + assignedTo: editingTask.assignedTo ?? "", }) } else { form.reset({ @@ -103,6 +110,8 @@ export function TaskFormDialog({ workdays: 5, phase: "preconstruction", isMilestone: false, + percentComplete: 0, + assignedTo: "", }) } }, [editingTask, form]) @@ -118,9 +127,15 @@ export function TaskFormDialog({ async function onSubmit(values: TaskFormValues) { let result if (isEditing) { - result = await updateTask(editingTask.id, values) + result = await updateTask(editingTask.id, { + ...values, + assignedTo: values.assignedTo || null, + }) } else { - result = await createTask(projectId, values) + result = await createTask(projectId, { + ...values, + assignedTo: values.assignedTo || undefined, + }) } if (result.success) { @@ -178,7 +193,17 @@ export function TaskFormDialog({ Workdays - + + field.onChange(Number(e.target.value) || 0) + } + onBlur={field.onBlur} + ref={field.ref} + name={field.name} + /> @@ -220,6 +245,42 @@ export function TaskFormDialog({ )} /> + ( + + + Complete: {field.value}% + + + field.onChange(val)} + /> + + + + )} + /> + + ( + + Assigned To + + + + + + )} + /> + + +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({ + 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 ( + + + + + {isEditing ? "Edit Exception" : "New Workday Exception"} + + + +
+ + ( + + Title + + + + + + )} + /> + +
+ ( + + Start Date + + + + + + )} + /> + ( + + End Date + + + + + + )} + /> +
+ +
+ ( + + Category + + + + )} + /> + ( + + Recurrence + + + + )} + /> +
+ + ( + + Notes + +