From f653b65df42c9cfee8f263594f96db60efecc7f6 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Thu, 12 Mar 2026 13:55:14 -0500 Subject: [PATCH] feat: Add bulk import functionality for Master CG Tracker Excel files --- src/app/(app)/projects/page.tsx | 23 +- src/app/api/projects/bulk-import/route.ts | 71 ++++ src/components/excel/bulk-import-dialog.tsx | 356 ++++++++++++++++++++ src/lib/services/excel-service.ts | 303 +++++++++++++++++ 4 files changed, 748 insertions(+), 5 deletions(-) create mode 100644 src/app/api/projects/bulk-import/route.ts create mode 100644 src/components/excel/bulk-import-dialog.tsx diff --git a/src/app/(app)/projects/page.tsx b/src/app/(app)/projects/page.tsx index 7ee9986..3f07056 100644 --- a/src/app/(app)/projects/page.tsx +++ b/src/app/(app)/projects/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Plus, MoreHorizontal, Trash2, Pencil } from "lucide-react"; +import { Plus, MoreHorizontal, Trash2, Pencil, FileSpreadsheet } from "lucide-react"; import { format } from "date-fns"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -22,6 +22,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useProjects, useCreateProject, useDeleteProject } from "@/hooks/use-projects"; import { ProjectFormDialog } from "@/components/projects/project-form-dialog"; +import { BulkImportDialog } from "@/components/excel/bulk-import-dialog"; import type { CreateProjectInput } from "@/lib/validators/project"; const STATUS_STYLES: Record = { @@ -40,6 +41,7 @@ const PRIORITY_STYLES: Record = { export default function ProjectsPage() { const [showCreate, setShowCreate] = useState(false); + const [showBulkImport, setShowBulkImport] = useState(false); const { data: projects, isLoading } = useProjects(); const createProject = useCreateProject(); const deleteProject = useDeleteProject(); @@ -59,10 +61,16 @@ export default function ProjectsPage() { Manage your production projects

- +
+ + +
@@ -179,6 +187,11 @@ export default function ProjectsPage() { onSubmit={handleCreate} isPending={createProject.isPending} /> + +
); } diff --git a/src/app/api/projects/bulk-import/route.ts b/src/app/api/projects/bulk-import/route.ts new file mode 100644 index 0000000..217a9df --- /dev/null +++ b/src/app/api/projects/bulk-import/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { + parseMasterTrackerImport, + importProjectsFromTracker, +} from "@/lib/services/excel-service"; + +// POST /api/projects/bulk-import +// Parses Q1_2026 and Q2_2026 sheets from the Master CG Tracker file. +// Without ?commit=true → returns a preview of what will be imported. +// With ?commit=true → creates projects + deliverables in the DB. +export async function POST(request: Request) { + const { session, error } = await getAuthSession(); + if (error) return error; + + const organizationId = session!.user.organizationId; + if (!organizationId) { + return badRequest("User has no organization"); + } + + try { + const url = new URL(request.url); + const commit = url.searchParams.get("commit") === "true"; + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + if (!file) return badRequest("No file uploaded"); + + const buffer = Buffer.from(await file.arrayBuffer()); + + const { rows, errors, sheets, totalRows } = await parseMasterTrackerImport(buffer); + + if (!commit) { + return NextResponse.json({ + preview: true, + sheets, + totalRows, + validRows: rows.length, + errors, + // Return a sample for the preview table (first 25 rows) + rows: rows.slice(0, 25).map((r) => ({ + name: r.name, + quarter: r.quarter, + businessUnit: r.businessUnit, + formFactor: r.formFactor, + deliverableName: r.deliverableName, + deliverableStatus: r.deliverableStatus, + cmfSku: r.cmfSku, + assetCount: r.assetCount, + estimatedCost: r.estimatedCost, + sourceSheet: r.sourceSheet, + omgCode: r.omgCode, + })), + }); + } + + if (rows.length === 0) { + return badRequest("No valid rows to import"); + } + + const result = await importProjectsFromTracker(rows, organizationId); + + return NextResponse.json({ + imported: result.projectCount, + deliverables: result.deliverableCount, + errors, + }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/excel/bulk-import-dialog.tsx b/src/components/excel/bulk-import-dialog.tsx new file mode 100644 index 0000000..ce335a2 --- /dev/null +++ b/src/components/excel/bulk-import-dialog.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useState, useRef } from "react"; +import { + Upload, + FileSpreadsheet, + AlertTriangle, + Check, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; + +interface PreviewRow { + name: string; + quarter?: string; + businessUnit?: string; + formFactor?: string; + deliverableName: string; + deliverableStatus: string; + cmfSku?: string; + assetCount?: number; + estimatedCost?: number; + sourceSheet: string; + omgCode?: string; +} + +interface PreviewData { + preview: boolean; + sheets: string[]; + totalRows: number; + validRows: number; + errors: { row: number; sheet: string; message: string }[]; + rows: PreviewRow[]; +} + +const STATUS_LABELS: Record = { + NOT_STARTED: "Not Started", + IN_PROGRESS: "In Progress", + IN_REVIEW: "In Review", + APPROVED: "Approved", + ON_HOLD: "On Hold", +}; + +const STATUS_COLORS: Record = { + NOT_STARTED: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]", + IN_PROGRESS: "bg-[var(--status-in-progress)]/10 text-[var(--status-in-progress)]", + IN_REVIEW: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]", + APPROVED: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]", + ON_HOLD: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]", +}; + +export function BulkImportDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [showErrors, setShowErrors] = useState(false); + const fileRef = useRef(null); + const queryClient = useQueryClient(); + + const reset = () => { + setFile(null); + setPreview(null); + setLoading(false); + setImporting(false); + setShowErrors(false); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (!selected) return; + + setFile(selected); + setLoading(true); + setPreview(null); + + try { + const formData = new FormData(); + formData.append("file", selected); + + const res = await fetch("/api/projects/bulk-import", { + method: "POST", + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to parse file"); + } + + const data: PreviewData = await res.json(); + setPreview(data); + } catch (e) { + toast.error("Parse failed", { + description: e instanceof Error ? e.message : "Unknown error", + }); + setFile(null); + } finally { + setLoading(false); + } + }; + + const handleImport = async () => { + if (!file) return; + setImporting(true); + + try { + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch("/api/projects/bulk-import?commit=true", { + method: "POST", + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Import failed"); + } + + const result = await res.json(); + toast.success( + `Imported ${result.imported} project${result.imported !== 1 ? "s" : ""} with ${result.deliverables} deliverable${result.deliverables !== 1 ? "s" : ""}` + ); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + onOpenChange(false); + reset(); + } catch (e) { + toast.error("Import failed", { + description: e instanceof Error ? e.message : "Unknown error", + }); + } finally { + setImporting(false); + } + }; + + // Counts by sheet + const q1Count = preview?.rows.filter((r) => r.sourceSheet === "Q1_2026").length ?? 0; + const q2Count = preview?.rows.filter((r) => r.sourceSheet === "Q2_2026").length ?? 0; + + return ( + { + onOpenChange(v); + if (!v) reset(); + }} + > + + + + + Import Projects from Master CG Tracker + + + + {/* Upload area */} + {!preview && ( +
+

+ Upload your Master CG Tracker Excel file. The{" "} + Q1_2026 and{" "} + Q2_2026{" "} + sheets will be imported. Each row becomes a project with one deliverable. +

+
fileRef.current?.click()} + > + +
+

+ {loading ? "Parsing file…" : "Click to upload .xlsx file"} +

+

+ Or drag and drop +

+
+
+ +
+ )} + + {/* Preview */} + {preview && ( +
+ {/* Summary badges */} +
+ {preview.sheets.map((s) => ( + + {s} + + ))} + + {preview.validRows} project{preview.validRows !== 1 ? "s" : ""} to import + + {q1Count > 0 && ( + + Q1: {q1Count} rows + + )} + {q2Count > 0 && ( + + Q2: {q2Count} rows + + )} + {preview.errors.length > 0 && ( + {preview.errors.length} skipped + )} +
+ + {/* Errors (collapsible) */} + {preview.errors.length > 0 && ( +
+ + {showErrors && ( +
+ {preview.errors.map((err, i) => ( +

+ [{err.sheet}] Row {err.row}: {err.message} +

+ ))} +
+ )} +
+ )} + + {/* Preview table */} +
+ + + + + + + + + + + + + + + {preview.rows.map((row, i) => ( + + + + + + + + + + + ))} + +
SheetProject / DeliverableBUForm FactorCMF/SKUAssetsCostStatus
+ {row.sourceSheet === "Q1_2026" ? "Q1" : "Q2"} + +

{row.name}

+ {row.deliverableName !== row.name && ( +

+ ↳ {row.deliverableName} +

+ )} +
+ {row.businessUnit ?? "—"} + + {row.formFactor ?? "—"} + + {row.cmfSku ?? "—"} + + {row.assetCount ?? "—"} + + {row.estimatedCost != null + ? `$${row.estimatedCost.toLocaleString()}` + : "—"} + + + {STATUS_LABELS[row.deliverableStatus] ?? row.deliverableStatus} + +
+ {preview.validRows > 25 && ( +

+ Showing 25 of {preview.validRows} rows — all will be imported +

+ )} +
+
+ )} + + + {!preview && !loading && ( + + )} + {preview && ( + <> + + + + )} + +
+
+ ); +} diff --git a/src/lib/services/excel-service.ts b/src/lib/services/excel-service.ts index 434489f..87dcc13 100644 --- a/src/lib/services/excel-service.ts +++ b/src/lib/services/excel-service.ts @@ -208,6 +208,309 @@ export async function exportProjectToExcel(projectId: string) { return wb; } +// ─── Master CG Tracker Bulk Import ────────────────────── + +export interface ProjectImportRow { + // Project fields + projectCode: string; + name: string; + codeName?: string; + quarter?: string; + businessUnit?: string; + formFactor?: string; + workfrontId?: string; + omgCode?: string; + bmtId?: string; + estimatedCost?: number; + requestor?: string; + // Deliverable fields + deliverableName: string; + deliverableStatus: string; + cmfSku?: string; + assetCount?: number; + dueDate?: string; + requestedDueDate?: string; + plannedDeliveryDate?: string; + wfInputDate?: string; + notes?: string; + // Meta + sourceSheet: string; + sourceRow: number; +} + +function mapDeliverableStatus(raw: string | undefined | null): string { + const s = String(raw ?? "").toLowerCase().trim(); + if (!s || s === "null" || s === "undefined") return "NOT_STARTED"; + if (s.includes("deliver") || s.includes("dam") || s.includes("complete")) return "APPROVED"; + if (s.includes("approved") && !s.includes("pending") && !s.includes("r0")) return "APPROVED"; + if (s.includes("in progress") || s.includes("wip")) return "IN_PROGRESS"; + if (s.includes("review") || s.includes("pending") || s.includes("r01") || s.includes("r02")) return "IN_REVIEW"; + if (s.includes("hold")) return "ON_HOLD"; + return "NOT_STARTED"; +} + +function safeStr(v: unknown, maxLen = 100): string | undefined { + if (v == null) return undefined; + const s = String(v).trim(); + return s && s !== "null" && s !== "undefined" ? s.substring(0, maxLen) : undefined; +} + +function safeDate(v: unknown): string | undefined { + if (v == null) return undefined; + let d: Date; + if (v instanceof Date) { + d = v; + } else if (typeof v === "number") { + // Excel serial date → JS timestamp + d = new Date(Math.round((v - 25569) * 86400 * 1000)); + } else { + const s = String(v).trim(); + if (!s || s === "NaT" || s === "null") return undefined; + d = new Date(s); + } + return isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +function safeNum(v: unknown): number | undefined { + if (v == null) return undefined; + const n = Number(v); + return isNaN(n) ? undefined : n; +} + +function genProjectCode(qtr: string | undefined, bu: string | undefined, idx: number): string { + // "FY26Q1" → "Q1", "FY26Q2" → "Q2", "Q1" → "Q1" + const qPart = (qtr ?? "QX").replace(/^FY\d{2}/, "").trim() || "QX"; + const buPart = (bu ?? "GEN").trim().replace(/\s+/g, "").substring(0, 3).toUpperCase(); + return `${qPart}-${buPart}-${String(idx).padStart(3, "0")}`; +} + +export async function parseMasterTrackerImport(buffer: Buffer): Promise<{ + rows: ProjectImportRow[]; + errors: { row: number; sheet: string; message: string }[]; + sheets: string[]; + totalRows: number; +}> { + const XLSX = await import("xlsx"); + const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true }); + + const TARGET_SHEETS = ["Q1_2026", "Q2_2026"]; + const availableSheets = workbook.SheetNames.filter((s) => TARGET_SHEETS.includes(s)); + + if (availableSheets.length === 0) { + throw new Error( + `No Q1_2026 or Q2_2026 sheets found. Available sheets: ${workbook.SheetNames.join(", ")}` + ); + } + + const rows: ProjectImportRow[] = []; + const errors: { row: number; sheet: string; message: string }[] = []; + let totalRows = 0; + let globalIdx = 1; + + for (const sheetName of TARGET_SHEETS) { + if (!workbook.SheetNames.includes(sheetName)) continue; + + const sheet = workbook.Sheets[sheetName]; + const jsonRows = XLSX.utils.sheet_to_json>(sheet, { + defval: null, + }); + + totalRows += jsonRows.length; + + for (let i = 0; i < jsonRows.length; i++) { + // Normalize: trim all column name keys + const r: Record = {}; + for (const [k, v] of Object.entries(jsonRows[i])) { + r[k.trim()] = v; + } + + const rowNum = i + 2; // 1-indexed + header row + + const codeName = safeStr(r["Code Names"], 200); + if (!codeName) { + // Silently skip fully blank rows + if (Object.values(r).every((v) => v == null)) continue; + errors.push({ row: rowNum, sheet: sheetName, message: 'Missing "Code Names" — row skipped' }); + continue; + } + + const qtr = safeStr(r["Qtr"]); + const bu = safeStr(r["BU"]); + const type = safeStr(r["Type"]); + + // Deliverable name = Code Names [+ Type if not already embedded] + const deliverableName = + type && !codeName.toLowerCase().includes(type.toLowerCase()) + ? `${codeName} — ${type}` + : codeName; + + // CMF/SKU handling differs between Q1 (has Color column) and Q2 + let cmfSku: string | undefined; + if (sheetName === "Q1_2026") { + const color = safeStr(r["Color"]); + // In Q1, CMF/Sku column is typically a count (1.0, 2.0) — use Color as CMF name + cmfSku = color; + } else { + cmfSku = safeStr(r["CMF/Sku"], 200); + } + + // Status + const statusRaw = + sheetName === "Q1_2026" ? r["Team Status"] : (r["Status"] ?? r[" Status"]); + const deliverableStatus = mapDeliverableStatus(safeStr(statusRaw, 200)); + + // Dates + let dueDate: string | undefined; + let requestedDueDate: string | undefined; + let plannedDeliveryDate: string | undefined; + let wfInputDate: string | undefined; + + if (sheetName === "Q1_2026") { + dueDate = safeDate(r["ETA"]); + plannedDeliveryDate = dueDate; + } else { + plannedDeliveryDate = safeDate(r["Delivery Date"]); + dueDate = plannedDeliveryDate; + requestedDueDate = safeDate(r["Requested Due Date"]); + wfInputDate = safeDate(r["WF Input Date"]); + } + + // Notes: combine Remark + Remarks + const noteParts = [safeStr(r["Remark"], 1000), safeStr(r["Remarks"], 1000)].filter(Boolean); + const notes = noteParts.length > 0 ? noteParts.join("\n") : undefined; + + const assetRaw = safeNum(r["#Asset"]); + const assetCount = assetRaw != null ? Math.round(assetRaw) : undefined; + + const estimatedCost = safeNum(r["Costs"]); + + rows.push({ + projectCode: genProjectCode(qtr, bu, globalIdx++), + name: codeName.substring(0, 200), + codeName: codeName.substring(0, 100), + quarter: qtr, + businessUnit: bu, + formFactor: safeStr(r["Form Factor"]), + workfrontId: safeStr(r["WF"]), + omgCode: safeStr(r["OMG Code"]), + bmtId: safeStr(r["BMT ID"]), + estimatedCost: estimatedCost != null && estimatedCost >= 0 ? estimatedCost : undefined, + requestor: safeStr(r["Requestor"], 500), + deliverableName: deliverableName.substring(0, 200), + deliverableStatus, + cmfSku, + assetCount, + dueDate, + requestedDueDate, + plannedDeliveryDate, + wfInputDate, + notes, + sourceSheet: sheetName, + sourceRow: rowNum, + }); + } + } + + return { rows, errors, sheets: availableSheets, totalRows }; +} + +export async function importProjectsFromTracker( + rows: ProjectImportRow[], + organizationId: string +): Promise<{ projectCount: number; deliverableCount: number }> { + // Fetch existing codes to avoid collisions + const existing = await prisma.project.findMany({ + select: { projectCode: true }, + where: { organizationId }, + }); + const usedCodes = new Set(existing.map((p) => p.projectCode)); + + // Resolve unique codes for this batch + const resolvedRows = rows.map((row) => { + let code = row.projectCode; + let attempt = 0; + while (usedCodes.has(code)) { + attempt++; + code = `${row.projectCode}-${String(attempt)}`; + } + usedCodes.add(code); + return { ...row, projectCode: code }; + }); + + // Pipeline stage templates for auto-creating stages + const templates = await prisma.pipelineStageTemplate.findMany({ + include: { dependsOn: true }, + orderBy: { order: "asc" }, + }); + + let projectCount = 0; + let deliverableCount = 0; + + // Import in batches of 50 to avoid transaction timeouts + const BATCH_SIZE = 50; + for (let start = 0; start < resolvedRows.length; start += BATCH_SIZE) { + const batch = resolvedRows.slice(start, start + BATCH_SIZE); + + await prisma.$transaction( + async (tx) => { + for (const row of batch) { + const project = await tx.project.create({ + data: { + projectCode: row.projectCode, + name: row.name, + codeName: row.codeName, + quarter: row.quarter, + businessUnit: row.businessUnit, + formFactor: row.formFactor, + workfrontId: row.workfrontId, + omgCode: row.omgCode, + bmtId: row.bmtId, + estimatedCost: row.estimatedCost, + requestor: row.requestor, + status: "ACTIVE", + priority: "MEDIUM", + organizationId, + }, + }); + projectCount++; + + const deliverable = await tx.deliverable.create({ + data: { + projectId: project.id, + name: row.deliverableName, + status: row.deliverableStatus as "NOT_STARTED" | "IN_PROGRESS" | "IN_REVIEW" | "APPROVED" | "ON_HOLD", + priority: "MEDIUM", + cmfSku: row.cmfSku, + assetCount: row.assetCount, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + requestedDueDate: row.requestedDueDate ? new Date(row.requestedDueDate) : null, + plannedDeliveryDate: row.plannedDeliveryDate ? new Date(row.plannedDeliveryDate) : null, + wfInputDate: row.wfInputDate ? new Date(row.wfInputDate) : null, + notes: row.notes, + }, + }); + deliverableCount++; + + // Auto-create pipeline stages + if (templates.length > 0) { + await tx.deliverableStage.createMany({ + data: templates.map((t) => ({ + deliverableId: deliverable.id, + templateId: t.id, + status: t.dependsOn.length > 0 ? ("BLOCKED" as const) : ("NOT_STARTED" as const), + dueDate: row.dueDate ? new Date(row.dueDate) : null, + })), + }); + } + } + }, + { timeout: 30000 } + ); + } + + return { projectCount, deliverableCount }; +} + // ─── Import ───────────────────────────────────────────── export interface ImportRow {