feat: Add bulk import functionality for Master CG Tracker Excel files

This commit is contained in:
Leivur Djurhuus 2026-03-12 13:55:14 -05:00
parent 877bc085dd
commit f653b65df4
4 changed files with 748 additions and 5 deletions

View file

@ -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>
);
}

View 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);
}
}

View 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>
);
}

View file

@ -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 {