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 (
+
+ );
+}
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 {