feat: Add bulk import functionality for Master CG Tracker Excel files
This commit is contained in:
parent
877bc085dd
commit
f653b65df4
4 changed files with 748 additions and 5 deletions
|
|
@ -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<string, string> = {
|
||||
|
|
@ -40,6 +41,7 @@ const PRIORITY_STYLES: Record<string, string> = {
|
|||
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Project
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setShowBulkImport(true)}>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Import from Tracker
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -179,6 +187,11 @@ export default function ProjectsPage() {
|
|||
onSubmit={handleCreate}
|
||||
isPending={createProject.isPending}
|
||||
/>
|
||||
|
||||
<BulkImportDialog
|
||||
open={showBulkImport}
|
||||
onOpenChange={setShowBulkImport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
71
src/app/api/projects/bulk-import/route.ts
Normal file
71
src/app/api/projects/bulk-import/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
356
src/components/excel/bulk-import-dialog.tsx
Normal file
356
src/components/excel/bulk-import-dialog.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
NOT_STARTED: "Not Started",
|
||||
IN_PROGRESS: "In Progress",
|
||||
IN_REVIEW: "In Review",
|
||||
APPROVED: "Approved",
|
||||
ON_HOLD: "On Hold",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
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<File | null>(null);
|
||||
const [preview, setPreview] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setLoading(false);
|
||||
setImporting(false);
|
||||
setShowErrors(false);
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
if (!v) reset();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Import Projects from Master CG Tracker
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Upload area */}
|
||||
{!preview && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Upload your <strong>Master CG Tracker</strong> Excel file. The{" "}
|
||||
<code className="rounded bg-[var(--muted)] px-1 py-0.5 text-xs">Q1_2026</code> and{" "}
|
||||
<code className="rounded bg-[var(--muted)] px-1 py-0.5 text-xs">Q2_2026</code>{" "}
|
||||
sheets will be imported. Each row becomes a project with one deliverable.
|
||||
</p>
|
||||
<div
|
||||
className="flex cursor-pointer flex-col items-center gap-3 rounded-md border-2 border-dashed p-10 text-center transition-colors hover:border-[var(--primary)]/50"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-[var(--muted-foreground)]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{loading ? "Parsing file…" : "Click to upload .xlsx file"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
|
||||
Or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary badges */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{preview.sheets.map((s) => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-xs">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="secondary">
|
||||
{preview.validRows} project{preview.validRows !== 1 ? "s" : ""} to import
|
||||
</Badge>
|
||||
{q1Count > 0 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
Q1: {q1Count} rows
|
||||
</span>
|
||||
)}
|
||||
{q2Count > 0 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
Q2: {q2Count} rows
|
||||
</span>
|
||||
)}
|
||||
{preview.errors.length > 0 && (
|
||||
<Badge variant="destructive">{preview.errors.length} skipped</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors (collapsible) */}
|
||||
{preview.errors.length > 0 && (
|
||||
<div className="rounded border border-[var(--status-blocked)]/20 bg-[var(--status-blocked)]/5">
|
||||
<button
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-[var(--status-blocked)]"
|
||||
onClick={() => setShowErrors((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{preview.errors.length} row{preview.errors.length !== 1 ? "s" : ""} skipped
|
||||
</span>
|
||||
{showErrors ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
{showErrors && (
|
||||
<div className="max-h-28 overflow-y-auto border-t border-[var(--status-blocked)]/20 px-3 py-2">
|
||||
{preview.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-[var(--status-blocked)]">
|
||||
[{err.sheet}] Row {err.row}: {err.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview table */}
|
||||
<div className="max-h-72 overflow-y-auto rounded border text-xs">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-[var(--muted)]">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-medium">Sheet</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">Project / Deliverable</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">BU</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">Form Factor</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">CMF/SKU</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">Assets</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">Cost</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.rows.map((row, i) => (
|
||||
<tr key={i} className="border-t hover:bg-[var(--muted)]/40">
|
||||
<td className="whitespace-nowrap px-2 py-1 font-mono text-[10px] text-[var(--muted-foreground)]">
|
||||
{row.sourceSheet === "Q1_2026" ? "Q1" : "Q2"}
|
||||
</td>
|
||||
<td className="max-w-[220px] px-2 py-1">
|
||||
<p className="truncate font-medium leading-tight">{row.name}</p>
|
||||
{row.deliverableName !== row.name && (
|
||||
<p className="truncate text-[10px] text-[var(--muted-foreground)]">
|
||||
↳ {row.deliverableName}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1 text-[var(--muted-foreground)]">
|
||||
{row.businessUnit ?? "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1 text-[var(--muted-foreground)]">
|
||||
{row.formFactor ?? "—"}
|
||||
</td>
|
||||
<td className="max-w-[100px] truncate px-2 py-1 text-[var(--muted-foreground)]">
|
||||
{row.cmfSku ?? "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-[var(--muted-foreground)]">
|
||||
{row.assetCount ?? "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-[var(--muted-foreground)]">
|
||||
{row.estimatedCost != null
|
||||
? `$${row.estimatedCost.toLocaleString()}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_COLORS[row.deliverableStatus] ?? ""}`}
|
||||
>
|
||||
{STATUS_LABELS[row.deliverableStatus] ?? row.deliverableStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{preview.validRows > 25 && (
|
||||
<p className="border-t px-3 py-2 text-center text-xs text-[var(--muted-foreground)]">
|
||||
Showing 25 of {preview.validRows} rows — all will be imported
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!preview && !loading && (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Choose Different File
|
||||
</Button>
|
||||
<Button
|
||||
disabled={importing || preview.validRows === 0}
|
||||
onClick={handleImport}
|
||||
>
|
||||
<Check className="mr-1.5 h-4 w-4" />
|
||||
{importing
|
||||
? "Importing…"
|
||||
: `Import ${preview.validRows} Project${preview.validRows !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Record<string, unknown>>(sheet, {
|
||||
defval: null,
|
||||
});
|
||||
|
||||
totalRows += jsonRows.length;
|
||||
|
||||
for (let i = 0; i < jsonRows.length; i++) {
|
||||
// Normalize: trim all column name keys
|
||||
const r: Record<string, unknown> = {};
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue