diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index d2157334e..1677f63b8 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -241,6 +241,31 @@ export const companyBulkResultOutput = z.object({ message: z.string().nullable(), }); +export const companyImportRowInput = z.object({ + name: z.string(), + phone: z.string().optional(), + city: z.string().optional(), + stateCode: z.string().optional(), + industry: z.string().optional(), + subIndustry: z.string().optional(), + website: z.string().optional(), + description: z.string().optional(), +}); + +export type CompanyImportRowInput = z.infer; + +export const companyImportInput = z.object({ + rows: z.array(companyImportRowInput).min(1).max(5000), +}); + +export type CompanyImportInput = z.infer; + +export const companyImportOutput = z.object({ + created: z.number(), + skipped: z.number(), + skips: z.array(z.object({ row: z.number(), reason: z.string() })), +}); + export const companyEnrichOutput = z.object({ id: z.string(), queued: z.boolean(), diff --git a/apps/api/src/companies/companies.router.ts b/apps/api/src/companies/companies.router.ts index ac11ccd35..f5f738933 100644 --- a/apps/api/src/companies/companies.router.ts +++ b/apps/api/src/companies/companies.router.ts @@ -20,6 +20,8 @@ import { companyDetailOutput, companyEnrichOutput, companyIdInput, + companyImportInput, + companyImportOutput, companyListInput, companyListOutput, companyOptionOutput, @@ -186,4 +188,13 @@ export class CompaniesRouter { ) { return this.companies.setPrimaryContact(input.companyId, input.contactId); } + + @Mutation({ + input: companyImportInput, + output: companyImportOutput, + meta: restMeta("POST", "/companies/import", ["Companies"]), + }) + async import(@Input() input: z.infer) { + return this.companies.import(input); + } } diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 3960dc33c..0f656b189 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -1,9 +1,9 @@ import { type Db, - type EnrichmentStatus, + EnrichmentStatus, type Prisma, Prisma as PrismaNamespace, - type RecordSource, + RecordSource, } from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import type { FieldDefinitionWithOptions } from "@crm/db/fields"; @@ -41,6 +41,7 @@ import { import type { CompanyBulkOwnerInput, CompanyCreateInput, + CompanyImportInput, CompanyListInput, CompanyRow, CompanyUpdateInput, @@ -720,6 +721,43 @@ export class CompaniesService { }; } + async import(input: CompanyImportInput) { + const toCreate: Prisma.CompanyCreateManyInput[] = []; + const skips: { row: number; reason: string }[] = []; + + for (const [i, row] of input.rows.entries()) { + const name = row.name.trim(); + if (!name) { + skips.push({ row: i + 1, reason: "No name" }); + continue; + } + toCreate.push({ + name, + phone: row.phone?.trim() || null, + city: row.city?.trim() || null, + stateCode: row.stateCode?.trim() || null, + industry: row.industry?.trim() || null, + subIndustry: row.subIndustry?.trim() || null, + website: row.website?.trim() || null, + description: row.description?.trim() || null, + source: RecordSource.IMPORT, + enrichmentStatus: EnrichmentStatus.SKIPPED, + }); + } + + if (toCreate.length > 0) { + await this.db.company.createMany({ data: toCreate }); + } + + this.logger.log({ + message: "Companies imported", + created: toCreate.length, + skipped: skips.length, + }); + + return { created: toCreate.length, skipped: skips.length, skips }; + } + private translate(cause: unknown, id: string): never { if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { if (cause.code === "P2025") { diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..018e0e16d 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -16,7 +16,7 @@ const publicProcedure = t.procedure; import { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput } from "../activities/activities.contracts"; import { agentListOutput, agentReviseInput, agentReviseOutput, agentIdInput, agentFilesOutput, agentSaveFileInput, agentSaveFileOutput, agentByIdOutput, agentHistoryInput, agentHistoryOutput, agentActivityOutput, agentUpdateInput, agentUpdateOutput, agentDeployInput, agentDeployOutput, agentPauseOutput, agentResumeOutput, agentArchiveOutput, agentRestoreOutput, agentRemoveOutput, agentRunNowInput, agentRunNowOutput, agentRetryRunInput, agentRetryRunOutput, agentCancelRunInput, agentCancelRunOutput } from "../agent/agents.contracts"; import { apiKeyListInput, apiKeyListOutput, createApiKeyInput, createApiKeyOutput, revokeApiKeyInput, revokeApiKeyOutput } from "../api-keys/api-keys.contracts"; -import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput } from "../companies/companies.contracts"; +import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput, companyImportInput, companyImportOutput } from "../companies/companies.contracts"; import { contactListInput, contactListOutput, contactIdInput, contactByIdOutput, contactCreateInput, contactBasicOutput, contactUpdateArgs, contactNameOutput, contactEnrichOutput, contactBulkOwnerInput, bulkResultOutput, contactBulkCompanyInput, contactBulkInput, factDecisionInput, decideFactOutput } from "../contacts/contacts.contracts"; import { conversationListInput, conversationListOutput, builderListOutput, builderResourceSearchInput, builderResourcesOutput, conversationIdInput, builderConversationDetailOutput, conversationEventsInput, conversationEventsOutput, conversationSaveInput, conversationIdOutput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, builderResponseRatingOutput, conversationShareStatusOutput, conversationShareTokenOutput, sharedConversationInput, sharedConversationOutput } from "../conversations/conversations.contracts"; import { currencySettingsOutput, setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts"; @@ -204,6 +204,10 @@ const appRouter = t.router({ setPrimaryContact: publicProcedure .input(setPrimaryContactInput) .output(companySetPrimaryContactOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + import: publicProcedure + .input(companyImportInput) + .output(companyImportOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), contacts: t.router({ diff --git a/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx b/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx new file mode 100644 index 000000000..3678f2057 --- /dev/null +++ b/apps/app/app/(app)/[slug]/companies/import-companies-link.tsx @@ -0,0 +1,19 @@ +"use client"; + +import Upload from "@carbon/icons-react/es/Upload"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import Link from "next/link"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +export function ImportCompaniesLink() { + const workspaceUrl = useWorkspaceUrl(); + return ( + + ); +} diff --git a/apps/app/app/(app)/[slug]/companies/import/csv-parse.ts b/apps/app/app/(app)/[slug]/companies/import/csv-parse.ts new file mode 100644 index 000000000..7a2e7ba12 --- /dev/null +++ b/apps/app/app/(app)/[slug]/companies/import/csv-parse.ts @@ -0,0 +1,74 @@ +export type ParsedCsv = { headers: string[]; rows: string[][] }; + +export function parseCsv(text: string): ParsedCsv { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const records = parseRecords(normalized); + const [first, ...rest] = records; + if (!first) return { headers: [], rows: [] }; + return { headers: first, rows: rest.filter((r) => r.some(Boolean)) }; +} + +function parseRecords(text: string): string[][] { + const records: string[][] = []; + let pos = 0; + + while (pos <= text.length) { + const [fields, next] = parseRecord(text, pos); + records.push(fields); + pos = next; + if (pos >= text.length) break; + pos++; + } + + return records; +} + +function parseRecord(text: string, start: number): [string[], number] { + const fields: string[] = []; + let pos = start; + + while (pos <= text.length) { + const [field, next] = parseField(text, pos); + fields.push(field); + pos = next; + if (pos >= text.length || text[pos] === "\n") break; + pos++; + } + + return [fields, pos]; +} + +function parseField(text: string, start: number): [string, number] { + if (text[start] !== '"') { + const end = findUnquotedEnd(text, start); + return [text.slice(start, end), end]; + } + + let value = ""; + let pos = start + 1; + + while (pos < text.length) { + if (text[pos] === '"') { + if (text[pos + 1] === '"') { + value += '"'; + pos += 2; + } else { + pos++; + break; + } + } else { + value += text[pos]; + pos++; + } + } + + return [value, pos]; +} + +function findUnquotedEnd(text: string, start: number): number { + let pos = start; + while (pos < text.length && text[pos] !== "," && text[pos] !== "\n") { + pos++; + } + return pos; +} diff --git a/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx b/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx new file mode 100644 index 000000000..45e56190f --- /dev/null +++ b/apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx @@ -0,0 +1,463 @@ +"use client"; + +import CheckmarkFilled from "@carbon/icons-react/es/CheckmarkFilled"; +import ErrorFilled from "@carbon/icons-react/es/ErrorFilled"; +import Upload from "@carbon/icons-react/es/Upload"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { Spinner } from "@crm/ui/components/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@crm/ui/components/table"; +import { cn } from "@crm/ui/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import Link from "next/link"; +import { useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; +import { type ParsedCsv, parseCsv } from "./csv-parse"; + +type Step = "upload" | "map" | "result"; + +type CrmField = + | "name" + | "phone" + | "city" + | "stateCode" + | "industry" + | "subIndustry" + | "website" + | "description" + | "_skip"; + +const CRM_FIELDS: { value: CrmField; label: string; required?: true }[] = [ + { value: "name", label: "Name", required: true }, + { value: "phone", label: "Phone" }, + { value: "city", label: "City" }, + { value: "stateCode", label: "State / Region" }, + { value: "industry", label: "Industry" }, + { value: "subIndustry", label: "Sub-industry" }, + { value: "website", label: "Website" }, + { value: "description", label: "Description" }, +]; + +const PREVIEW_ROWS = 5; + +function guessMapping(header: string): CrmField { + const h = header.toLowerCase().replace(/[^a-z]/g, ""); + if (h === "name" || h === "company" || h === "companyname") return "name"; + if (h === "phone" || h === "telephone" || h === "mobile") return "phone"; + if (h === "city" || h === "town") return "city"; + if (h === "state" || h === "statecode" || h === "region" || h === "province") + return "stateCode"; + if (h === "industry" || h === "sector") return "industry"; + if (h === "subindustry" || h === "subsector" || h === "vertical") + return "subIndustry"; + if (h === "website" || h === "url" || h === "domain" || h === "web") + return "website"; + if (h === "description" || h === "about" || h === "notes") + return "description"; + return "_skip"; +} + +type ImportResult = { + created: number; + skipped: number; + skips: { row: number; reason: string }[]; +}; + +export function ImportWizard() { + const workspaceUrl = useWorkspaceUrl(); + const trpc = useTRPC(); + const cache = useCrmCache(); + + const [step, setStep] = useState("upload"); + const [parsed, setParsed] = useState(null); + const [mapping, setMapping] = useState>({}); + const [result, setResult] = useState(null); + const [dragOver, setDragOver] = useState(false); + const fileRef = useRef(null); + + const importMutation = useMutation( + trpc.companies.import.mutationOptions({ + onSuccess: async (data) => { + setResult(data); + setStep("result"); + if (data.created > 0) await cache.company(); + }, + onError: (err) => toast.error(err.message), + }), + ); + + const loadFile = useCallback((file: File) => { + if (!file.name.endsWith(".csv") && file.type !== "text/csv") { + toast.error("Please upload a .csv file."); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text !== "string") return; + const result = parseCsv(text); + if (result.headers.length === 0) { + toast.error("The CSV file has no headers."); + return; + } + const initial: Record = {}; + for (const h of result.headers) initial[h] = guessMapping(h); + setParsed(result); + setMapping(initial); + setStep("map"); + }; + reader.readAsText(file); + }, []); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) loadFile(file); + }, + [loadFile], + ); + + const onFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) loadFile(file); + }, + [loadFile], + ); + + const confirm = useCallback(() => { + if (!parsed) return; + const nameField = Object.entries(mapping).find( + ([, v]) => v === "name", + )?.[0]; + if (!nameField) { + toast.error("Map at least one column to Name before importing."); + return; + } + const rows = parsed.rows.map((row) => { + const obj: Record = {}; + for (const [header, field] of Object.entries(mapping)) { + if (field === "_skip") continue; + const idx = parsed.headers.indexOf(header); + obj[field] = row[idx] ?? ""; + } + return obj as { + name: string; + phone?: string; + city?: string; + stateCode?: string; + industry?: string; + subIndustry?: string; + website?: string; + description?: string; + }; + }); + importMutation.mutate({ rows }); + }, [parsed, mapping, importMutation]); + + const mappedFields = Object.values(mapping).filter((v) => v !== "_skip"); + const previewRows = parsed?.rows.slice(0, PREVIEW_ROWS) ?? []; + const previewHeaders = + parsed?.headers.filter((h) => mapping[h] !== "_skip") ?? []; + + return ( +
+ + + {step === "upload" && ( +
+ + +
+ )} + + {step === "map" && parsed && ( +
+
+

+ Map CSV columns to company fields +

+
+ + + + CSV column + CRM field + + + + {parsed.headers.map((header) => ( + + + {header} + + + + + + ))} + +
+
+
+ + {previewHeaders.length > 0 && ( +
+

+ Preview — first {Math.min(PREVIEW_ROWS, parsed.rows.length)}{" "} + rows +

+
+ + + + {previewHeaders.map((h) => ( + + {CRM_FIELDS.find((f) => f.value === mapping[h]) + ?.label ?? mapping[h]} + + ))} + + + + {previewRows.map((row) => ( + + {previewHeaders.map((h) => ( + + {row[parsed.headers.indexOf(h)] || ( + + )} + + ))} + + ))} + +
+
+
+ )} + +

+ {parsed.rows.length} row{parsed.rows.length !== 1 ? "s" : ""}{" "} + detected.{" "} + {mappedFields.includes("name") ? null : ( + + Map a column to Name to continue. + + )} +

+ +
+ + +
+
+ )} + + {step === "result" && result && ( +
+
+
+ + + {result.created} compan{result.created !== 1 ? "ies" : "y"}{" "} + imported + +
+ {result.skipped > 0 && ( +
+ + + {result.skipped} row{result.skipped !== 1 ? "s" : ""} skipped + +
+ )} +
+ + {result.skips.length > 0 && ( +
+ + + + Row + Reason skipped + + + + {result.skips.map((s) => ( + + {s.row} + + {s.reason} + + + ))} + +
+
+ )} + +
+ + +
+
+ )} +
+ ); +} + +function Steps({ current }: { current: Step }) { + const steps: { key: Step; label: string }[] = [ + { key: "upload", label: "Upload" }, + { key: "map", label: "Map columns" }, + { key: "result", label: "Result" }, + ]; + const currentIdx = steps.findIndex((s) => s.key === current); + return ( +
    + {steps.map((s, i) => { + const done = i < currentIdx; + const active = i === currentIdx; + return ( +
  1. + + + {i + 1} + + {s.label} + + {i < steps.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/companies/import/page.tsx b/apps/app/app/(app)/[slug]/companies/import/page.tsx new file mode 100644 index 000000000..74bdc9bd9 --- /dev/null +++ b/apps/app/app/(app)/[slug]/companies/import/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { ImportWizard } from "./import-wizard"; + +export const metadata: Metadata = { + title: "Import Companies", +}; + +export default function ImportCompaniesPage() { + return ( + + + + Import Companies from CSV + + Upload a CSV file, map its columns to company fields, and create + records in bulk. + + + + + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/companies/page.tsx b/apps/app/app/(app)/[slug]/companies/page.tsx index 95f0f2472..669735161 100644 --- a/apps/app/app/(app)/[slug]/companies/page.tsx +++ b/apps/app/app/(app)/[slug]/companies/page.tsx @@ -16,6 +16,7 @@ import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; import { companiesSearchParams } from "./companies-search-params"; import { CompaniesTable } from "./companies-table"; import { CreateCompanySheet } from "./create-company-sheet"; +import { ImportCompaniesLink } from "./import-companies-link"; export const metadata: Metadata = { title: "Companies", @@ -34,6 +35,7 @@ export default function CompaniesPage({ +