diff --git a/bun.lockb b/bun.lockb index 2045680..a940daa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6d7d167..17dc66c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@tiptap/starter-kit": "^3.22.3", "@types/file-saver": "^2.0.7", "@types/mailparser": "3.4.4", + "@types/papaparse": "^5.5.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -68,6 +69,7 @@ "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", "mailparser": "3.7.1", + "papaparse": "^5.5.3", "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c990442..100e462 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -26,6 +26,7 @@ export type Database = { created_by: string | null direction: string duration_minutes: number | null + external_id: string | null follow_up_date: string | null follow_up_required: boolean homeowner_id: string | null @@ -45,6 +46,7 @@ export type Database = { created_by?: string | null direction?: string duration_minutes?: number | null + external_id?: string | null follow_up_date?: string | null follow_up_required?: boolean homeowner_id?: string | null @@ -64,6 +66,7 @@ export type Database = { created_by?: string | null direction?: string duration_minutes?: number | null + external_id?: string | null follow_up_date?: string | null follow_up_required?: boolean homeowner_id?: string | null @@ -153,6 +156,7 @@ export type Database = { created_by: string | null default_hourly_rate: number | null description: string | null + external_id: string | null filing_date: string | null id: string judge: string | null @@ -188,6 +192,7 @@ export type Database = { created_by?: string | null default_hourly_rate?: number | null description?: string | null + external_id?: string | null filing_date?: string | null id?: string judge?: string | null @@ -223,6 +228,7 @@ export type Database = { created_by?: string | null default_hourly_rate?: number | null description?: string | null + external_id?: string | null filing_date?: string | null id?: string judge?: string | null @@ -313,6 +319,7 @@ export type Database = { client_type: Database["public"]["Enums"]["client_type"] created_at: string created_by: string | null + external_id: string | null id: string management_company: string | null name: string @@ -334,6 +341,7 @@ export type Database = { client_type?: Database["public"]["Enums"]["client_type"] created_at?: string created_by?: string | null + external_id?: string | null id?: string management_company?: string | null name: string @@ -355,6 +363,7 @@ export type Database = { client_type?: Database["public"]["Enums"]["client_type"] created_at?: string created_by?: string | null + external_id?: string | null id?: string management_company?: string | null name?: string @@ -741,6 +750,7 @@ export type Database = { created_at: string created_by: string | null email: string | null + external_id: string | null id: string name: string notes: string | null @@ -759,6 +769,7 @@ export type Database = { created_at?: string created_by?: string | null email?: string | null + external_id?: string | null id?: string name: string notes?: string | null @@ -777,6 +788,7 @@ export type Database = { created_at?: string created_by?: string | null email?: string | null + external_id?: string | null id?: string name?: string notes?: string | null @@ -926,6 +938,7 @@ export type Database = { case_id: string created_at: string description: string | null + external_id: string | null folder: string id: string mime_type: string | null @@ -938,6 +951,7 @@ export type Database = { case_id: string created_at?: string description?: string | null + external_id?: string | null folder?: string id?: string mime_type?: string | null @@ -950,6 +964,7 @@ export type Database = { case_id?: string created_at?: string description?: string | null + external_id?: string | null folder?: string id?: string mime_type?: string | null @@ -1025,6 +1040,72 @@ export type Database = { } Relationships: [] } + events: { + Row: { + all_day: boolean + case_id: string | null + client_id: string | null + created_at: string + created_by: string | null + description: string | null + end_at: string | null + event_type: string + external_id: string | null + id: string + location: string | null + start_at: string + title: string + updated_at: string + } + Insert: { + all_day?: boolean + case_id?: string | null + client_id?: string | null + created_at?: string + created_by?: string | null + description?: string | null + end_at?: string | null + event_type?: string + external_id?: string | null + id?: string + location?: string | null + start_at: string + title: string + updated_at?: string + } + Update: { + all_day?: boolean + case_id?: string | null + client_id?: string | null + created_at?: string + created_by?: string | null + description?: string | null + end_at?: string | null + event_type?: string + external_id?: string | null + id?: string + location?: string | null + start_at?: string + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "events_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "events_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } expenses: { Row: { amount: number @@ -1033,6 +1114,7 @@ export type Database = { created_at: string description: string expense_date: string + external_id: string | null id: string invoice_id: string | null receipt_storage_path: string | null @@ -1046,6 +1128,7 @@ export type Database = { created_at?: string description: string expense_date?: string + external_id?: string | null id?: string invoice_id?: string | null receipt_storage_path?: string | null @@ -1059,6 +1142,7 @@ export type Database = { created_at?: string description?: string expense_date?: string + external_id?: string | null id?: string invoice_id?: string | null receipt_storage_path?: string | null @@ -1241,6 +1325,7 @@ export type Database = { created_at: string created_by: string | null email: string | null + external_id: string | null first_name: string id: string last_name: string @@ -1256,6 +1341,7 @@ export type Database = { created_at?: string created_by?: string | null email?: string | null + external_id?: string | null first_name: string id?: string last_name: string @@ -1271,6 +1357,7 @@ export type Database = { created_at?: string created_by?: string | null email?: string | null + external_id?: string | null first_name?: string id?: string last_name?: string @@ -1559,6 +1646,7 @@ export type Database = { created_at: string created_by: string | null due_date: string | null + external_id: string | null id: string invoice_number: string is_retainer: boolean @@ -1578,6 +1666,7 @@ export type Database = { created_at?: string created_by?: string | null due_date?: string | null + external_id?: string | null id?: string invoice_number: string is_retainer?: boolean @@ -1597,6 +1686,7 @@ export type Database = { created_at?: string created_by?: string | null due_date?: string | null + external_id?: string | null id?: string invoice_number?: string is_retainer?: boolean @@ -2023,6 +2113,7 @@ export type Database = { case_id: string created_at: string created_by: string | null + external_id: string | null id: string report_storage_path: string | null title: string @@ -2032,6 +2123,7 @@ export type Database = { case_id: string created_at?: string created_by?: string | null + external_id?: string | null id?: string report_storage_path?: string | null title: string @@ -2041,6 +2133,7 @@ export type Database = { case_id?: string created_at?: string created_by?: string | null + external_id?: string | null id?: string report_storage_path?: string | null title?: string @@ -2166,6 +2259,7 @@ export type Database = { created_by: string | null description: string | null due_date: string | null + external_id: string | null id: string parent_task_id: string | null priority: Database["public"]["Enums"]["task_priority"] @@ -2183,6 +2277,7 @@ export type Database = { created_by?: string | null description?: string | null due_date?: string | null + external_id?: string | null id?: string parent_task_id?: string | null priority?: Database["public"]["Enums"]["task_priority"] @@ -2200,6 +2295,7 @@ export type Database = { created_by?: string | null description?: string | null due_date?: string | null + external_id?: string | null id?: string parent_task_id?: string | null priority?: Database["public"]["Enums"]["task_priority"] @@ -2239,6 +2335,7 @@ export type Database = { case_id: string created_at: string description: string + external_id: string | null hourly_rate: number hours: number id: string @@ -2252,6 +2349,7 @@ export type Database = { case_id: string created_at?: string description: string + external_id?: string | null hourly_rate?: number hours: number id?: string @@ -2265,6 +2363,7 @@ export type Database = { case_id?: string created_at?: string description?: string + external_id?: string | null hourly_rate?: number hours?: number id?: string diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 026bfc4..f509e07 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflo import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow' import { Route as SettingsSmtpRouteImport } from './routes/settings.smtp' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' +import { Route as SettingsImportRouteImport } from './routes/settings.import' import { Route as SettingsImapRouteImport } from './routes/settings.imap' import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' @@ -156,6 +157,11 @@ const SettingsProfileRoute = SettingsProfileRouteImport.update({ path: '/profile', getParentRoute: () => SettingsRoute, } as any) +const SettingsImportRoute = SettingsImportRouteImport.update({ + id: '/import', + path: '/import', + getParentRoute: () => SettingsRoute, +} as any) const SettingsImapRoute = SettingsImapRouteImport.update({ id: '/imap', path: '/imap', @@ -243,6 +249,7 @@ export interface FileRoutesByFullPath { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute '/settings/imap': typeof SettingsImapRoute + '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -280,6 +287,7 @@ export interface FileRoutesByTo { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute '/settings/imap': typeof SettingsImapRoute + '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -319,6 +327,7 @@ export interface FileRoutesById { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute '/settings/imap': typeof SettingsImapRoute + '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute @@ -359,6 +368,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/fees' | '/settings/imap' + | '/settings/import' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -396,6 +406,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/fees' | '/settings/imap' + | '/settings/import' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -434,6 +445,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/fees' | '/settings/imap' + | '/settings/import' | '/settings/profile' | '/settings/smtp' | '/settings/workflow' @@ -646,6 +658,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProfileRouteImport parentRoute: typeof SettingsRoute } + '/settings/import': { + id: '/settings/import' + path: '/import' + fullPath: '/settings/import' + preLoaderRoute: typeof SettingsImportRouteImport + parentRoute: typeof SettingsRoute + } '/settings/imap': { id: '/settings/imap' path: '/imap' @@ -750,6 +769,7 @@ declare module '@tanstack/react-router' { interface SettingsRouteChildren { SettingsFeesRoute: typeof SettingsFeesRoute SettingsImapRoute: typeof SettingsImapRoute + SettingsImportRoute: typeof SettingsImportRoute SettingsProfileRoute: typeof SettingsProfileRoute SettingsSmtpRoute: typeof SettingsSmtpRoute SettingsWorkflowRoute: typeof SettingsWorkflowRoute @@ -760,6 +780,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsFeesRoute: SettingsFeesRoute, SettingsImapRoute: SettingsImapRoute, + SettingsImportRoute: SettingsImportRoute, SettingsProfileRoute: SettingsProfileRoute, SettingsSmtpRoute: SettingsSmtpRoute, SettingsWorkflowRoute: SettingsWorkflowRoute, diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx new file mode 100644 index 0000000..666a49b --- /dev/null +++ b/src/routes/settings.import.tsx @@ -0,0 +1,717 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import Papa from "papaparse"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Upload, CheckCircle2, AlertCircle, Loader2, FileSpreadsheet } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/settings/import")({ + component: ImportPage, +}); + +type Row = Record; + +interface ImporterConfig { + key: string; + label: string; + description: string; + table: string; + conflict: string; // column for upsert + // Maps normalized csv header → db column + aliases: Record; + // Numeric columns (parsed from string) + numeric?: string[]; + // Date-only columns (YYYY-MM-DD) + dateCols?: string[]; + // Datetime columns + datetimeCols?: string[]; + // Boolean columns + boolCols?: string[]; + // Required output columns; if missing in row, row is skipped + required: string[]; + // Per-row transform after mapping (e.g. split full name, set defaults) + transform?: (row: Row, ctx: ImportCtx) => Row | null; + // After successful insert, hook for caching ids by external_id (e.g. clients) + postInsert?: (rows: Row[], ctx: ImportCtx) => void; +} + +interface ImportCtx { + userId: string; + // Cached lookup tables: external_id → uuid + clientByExt: Map; + caseByExt: Map; + contactByExt: Map; + homeownerByExt: Map; + invoiceByExt: Map; + // Name → uuid lookups (for files that reference by name only) + clientByName: Map; + caseByNumber: Map; +} + +const norm = (s: string) => + s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +const splitName = (full: string): { first_name: string; last_name: string } => { + const parts = (full ?? "").trim().split(/\s+/); + if (parts.length === 0) return { first_name: "", last_name: "" }; + if (parts.length === 1) return { first_name: parts[0], last_name: "" }; + return { first_name: parts[0], last_name: parts.slice(1).join(" ") }; +}; + +const toBool = (v: any) => { + if (typeof v === "boolean") return v; + const s = String(v ?? "").trim().toLowerCase(); + return s === "true" || s === "yes" || s === "1" || s === "y"; +}; + +const toNum = (v: any) => { + if (v === "" || v == null) return null; + const n = Number(String(v).replace(/[$,]/g, "")); + return isNaN(n) ? null : n; +}; + +const toDate = (v: any): string | null => { + if (!v) return null; + const d = new Date(v); + if (isNaN(d.getTime())) return null; + return d.toISOString().slice(0, 10); +}; + +const toDateTime = (v: any): string | null => { + if (!v) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : d.toISOString(); +}; + +const IMPORTERS: ImporterConfig[] = [ + // Companies → clients + { + key: "companies", + label: "Companies → Clients", + description: "HOAs and corporate clients.", + table: "clients", + conflict: "external_id", + required: ["name"], + aliases: { + id: "external_id", companyid: "external_id", externalid: "external_id", + name: "name", companyname: "name", company: "name", + type: "client_type", clienttype: "client_type", + addressline1: "address_line1", address1: "address_line1", address: "address_line1", + addressline2: "address_line2", address2: "address_line2", + city: "city", state: "state", + zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code", + phone: "primary_contact_phone", primaryphone: "primary_contact_phone", + email: "primary_contact_email", primaryemail: "primary_contact_email", + contactname: "primary_contact_name", primarycontact: "primary_contact_name", + management: "management_company", managementcompany: "management_company", + units: "num_units", numunits: "num_units", + notes: "notes", + }, + numeric: ["num_units", "annual_interest_rate"], + transform: (r) => { + const t = String(r.client_type ?? "").toLowerCase(); + r.client_type = t.includes("condo") ? "condo" : t.includes("hoa") ? "hoa" : "hoa"; + return r; + }, + postInsert: (rows, ctx) => { + for (const r of rows) { + if (r.external_id && r.id) ctx.clientByExt.set(r.external_id, r.id); + if (r.name && r.id) ctx.clientByName.set(r.name.toLowerCase(), r.id); + } + }, + }, + // People → contacts + { + key: "people", + label: "People → Contacts", + description: "Individual contacts (board members, vendors, etc.).", + table: "contacts", + conflict: "external_id", + required: ["name"], + aliases: { + id: "external_id", personid: "external_id", externalid: "external_id", + name: "name", fullname: "name", + firstname: "_first", lastname: "_last", + email: "email", phone: "phone", + company: "company", title: "title", + addressline1: "address_line1", address1: "address_line1", address: "address_line1", + addressline2: "address_line2", address2: "address_line2", + city: "city", state: "state", + zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code", + type: "contact_type", contacttype: "contact_type", + notes: "notes", + }, + transform: (r) => { + if (!r.name && (r._first || r._last)) { + r.name = [r._first, r._last].filter(Boolean).join(" ").trim(); + } + delete r._first; delete r._last; + if (!r.contact_type) r.contact_type = "other"; + return r; + }, + }, + // Lawyers → contacts (type=attorney) + { + key: "lawyers", + label: "Lawyers → Contacts", + description: "Opposing counsel & external attorneys (added as contacts).", + table: "contacts", + conflict: "external_id", + required: ["name"], + aliases: { + id: "external_id", lawyerid: "external_id", externalid: "external_id", + name: "name", fullname: "name", + firstname: "_first", lastname: "_last", + email: "email", phone: "phone", + firm: "company", company: "company", + addressline1: "address_line1", address: "address_line1", + city: "city", state: "state", zip: "postal_code", postalcode: "postal_code", + notes: "notes", + }, + transform: (r) => { + if (!r.name && (r._first || r._last)) r.name = [r._first, r._last].filter(Boolean).join(" ").trim(); + delete r._first; delete r._last; + r.contact_type = "attorney"; + return r; + }, + }, + // Locations → homeowners (units) + { + key: "locations", + label: "Locations → Homeowner addresses", + description: "Property addresses tied to a client (HOA unit list).", + table: "homeowners", + conflict: "external_id", + required: ["client_id", "first_name"], + aliases: { + id: "external_id", locationid: "external_id", externalid: "external_id", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", + address: "address", street: "address", addressline1: "address", + unit: "unit_number", unitnumber: "unit_number", + ownername: "_ownername", owner: "_ownername", + firstname: "first_name", lastname: "last_name", + email: "email", phone: "phone", + notes: "notes", + }, + transform: (r, ctx) => { + const cid = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null; + const cidByName = r._clientname ? ctx.clientByName.get(String(r._clientname).toLowerCase()) : null; + r.client_id = cid ?? cidByName ?? null; + delete r._clientext; delete r._clientname; + if (!r.client_id) return null; + if (!r.first_name && r._ownername) { + const { first_name, last_name } = splitName(r._ownername); + r.first_name = first_name; r.last_name = last_name; + } + delete r._ownername; + if (!r.last_name) r.last_name = ""; + return r; + }, + }, + // Cases → cases + { + key: "cases", + label: "Cases → Cases", + description: "Matters / cases linked to a client.", + table: "cases", + conflict: "external_id", + required: ["case_number", "title", "client_id"], + aliases: { + id: "external_id", caseid: "external_id", externalid: "external_id", + casenumber: "case_number", number: "case_number", mattercode: "case_number", + name: "title", title: "title", mattername: "title", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", client: "_clientname", + description: "description", summary: "case_summary", casesummary: "case_summary", + practicearea: "practice_area", area: "practice_area", + status: "status", + opened: "opened_at", openedat: "opened_at", dateopened: "opened_at", + closed: "closed_at", closedat: "closed_at", dateclosed: "closed_at", + court: "court", judge: "judge", jurisdiction: "jurisdiction", + caseCaption: "case_caption", caption: "case_caption", + courtcasenumber: "court_case_number", + filingdate: "filing_date", + nexthearing: "next_hearing_date", nexthearingdate: "next_hearing_date", + opposingparty: "opposing_party", + opposingcounsel: "opposing_counsel", + opposingcounselfirm: "opposing_counsel_firm", + opposingcounselemail: "opposing_counsel_email", + opposingcounselphone: "opposing_counsel_phone", + claimamount: "claim_amount", settlementamount: "settlement_amount", + hourlyrate: "default_hourly_rate", rate: "default_hourly_rate", + }, + numeric: ["claim_amount", "settlement_amount", "default_hourly_rate"], + dateCols: ["opened_at", "closed_at", "filing_date", "next_hearing_date", "statute_of_limitations"], + transform: (r, ctx) => { + const cid = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null; + const cidByName = r._clientname ? ctx.clientByName.get(String(r._clientname).toLowerCase()) : null; + r.client_id = cid ?? cidByName ?? null; + delete r._clientext; delete r._clientname; + if (!r.client_id) return null; + const s = String(r.status ?? "").toLowerCase(); + r.status = s.includes("close") ? "closed" : s.includes("hold") ? "on_hold" : s.includes("intake") ? "intake" : "active"; + if (!r.case_number) r.case_number = `CASE-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + if (!r.title) r.title = r.case_number; + return r; + }, + postInsert: (rows, ctx) => { + for (const r of rows) { + if (r.external_id && r.id) ctx.caseByExt.set(r.external_id, r.id); + if (r.case_number && r.id) ctx.caseByNumber.set(r.case_number, r.id); + } + }, + }, + // Case stages → not stored (informational only). Skipped. + // Tasks + { + key: "tasks", + label: "Tasks → Tasks", + description: "Task list with case linkage.", + table: "tasks", + conflict: "external_id", + required: ["title"], + aliases: { + id: "external_id", taskid: "external_id", externalid: "external_id", + title: "title", name: "title", subject: "title", + description: "description", notes: "description", + duedate: "due_date", due: "due_date", + priority: "priority", status: "status", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + dateCols: ["due_date"], + transform: (r, ctx) => { + const cid = r._caseext ? ctx.caseByExt.get(String(r._caseext)) : null; + const cidByNum = r._casenum ? ctx.caseByNumber.get(String(r._casenum)) : null; + r.case_id = cid ?? cidByNum ?? null; + delete r._caseext; delete r._casenum; + const s = String(r.status ?? "").toLowerCase(); + r.status = s.includes("complete") || s.includes("done") ? "complete" : "incomplete"; + const p = String(r.priority ?? "").toLowerCase(); + r.priority = ["low", "normal", "high", "urgent"].includes(p) ? p : "normal"; + return r; + }, + }, + // Events + { + key: "events", + label: "Events → Calendar", + description: "Calendar events (hearings, meetings, deadlines).", + table: "events", + conflict: "external_id", + required: ["title", "start_at"], + aliases: { + id: "external_id", eventid: "external_id", externalid: "external_id", + title: "title", name: "title", subject: "title", + description: "description", notes: "description", + location: "location", + start: "start_at", startat: "start_at", startdate: "start_at", startdatetime: "start_at", date: "start_at", + end: "end_at", endat: "end_at", enddate: "end_at", enddatetime: "end_at", + allday: "all_day", + type: "event_type", eventtype: "event_type", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + companyid: "_clientext", clientid: "_clientext", + }, + boolCols: ["all_day"], + datetimeCols: ["start_at", "end_at"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + r.client_id = (r._clientext && ctx.clientByExt.get(String(r._clientext))) || null; + delete r._caseext; delete r._casenum; delete r._clientext; + if (!r.event_type) r.event_type = "general"; + return r; + }, + }, + // Call logs + { + key: "call_logs", + label: "Calls → Call logs", + description: "Phone call records.", + table: "call_logs", + conflict: "external_id", + required: ["case_id"], + aliases: { + id: "external_id", callid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + direction: "direction", subject: "subject", topic: "subject", + notes: "notes", description: "notes", + callername: "caller_name", from: "caller_name", + callerphone: "caller_phone", phone: "caller_phone", + duration: "duration_minutes", durationminutes: "duration_minutes", minutes: "duration_minutes", + billable: "billable", + date: "call_date", calldate: "call_date", datetime: "call_date", + }, + numeric: ["duration_minutes"], + boolCols: ["billable"], + datetimeCols: ["call_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id) return null; + const d = String(r.direction ?? "").toLowerCase(); + r.direction = d.includes("in") ? "inbound" : "outbound"; + return r; + }, + }, + // Time entries + { + key: "time_entries", + label: "Time entries → Time", + description: "Billable time records.", + table: "time_entries", + conflict: "external_id", + required: ["case_id", "description", "hours"], + aliases: { + id: "external_id", timeid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + description: "description", notes: "description", + hours: "hours", duration: "hours", + rate: "hourly_rate", hourlyrate: "hourly_rate", + billable: "billable", + date: "work_date", workdate: "work_date", + }, + numeric: ["hours", "hourly_rate"], + boolCols: ["billable"], + dateCols: ["work_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id) return null; + r.user_id = ctx.userId; + if (r.hours == null) return null; + return r; + }, + }, + // Expenses + { + key: "expenses", + label: "Expenses → Expenses", + description: "Case-related expenses.", + table: "expenses", + conflict: "external_id", + required: ["case_id", "description", "amount"], + aliases: { + id: "external_id", expenseid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + description: "description", notes: "description", + amount: "amount", cost: "amount", + billable: "billable", + date: "expense_date", expensedate: "expense_date", + }, + numeric: ["amount"], + boolCols: ["billable"], + dateCols: ["expense_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || r.amount == null) return null; + r.user_id = ctx.userId; + return r; + }, + }, + // Invoices + { + key: "invoices", + label: "Invoices → Invoices", + description: "Invoice headers (line items not imported).", + table: "invoices", + conflict: "external_id", + required: ["invoice_number", "client_id"], + aliases: { + id: "external_id", invoiceid: "external_id", externalid: "external_id", + number: "invoice_number", invoicenumber: "invoice_number", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", + caseid: "_caseext", casenumber: "_casenum", + issuedate: "issue_date", date: "issue_date", + duedate: "due_date", + status: "status", + subtotal: "subtotal", tax: "tax", total: "total", amount: "total", + amountpaid: "amount_paid", paid: "amount_paid", + notes: "notes", memo: "notes", + }, + numeric: ["subtotal", "tax", "total", "amount_paid"], + dateCols: ["issue_date", "due_date"], + transform: (r, ctx) => { + r.client_id = (r._clientext && ctx.clientByExt.get(String(r._clientext))) || (r._clientname && ctx.clientByName.get(String(r._clientname).toLowerCase())) || null; + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._clientext; delete r._clientname; delete r._caseext; delete r._casenum; + if (!r.client_id) return null; + const s = String(r.status ?? "").toLowerCase(); + r.status = ["draft", "sent", "paid", "overdue", "void"].includes(s) ? s : "draft"; + return r; + }, + }, + // Notes → comments (general entity) + { + key: "notes", + label: "Notes → Comments", + description: "Free-form notes attached to cases.", + table: "comments", + conflict: "external_id", + required: ["body", "case_id"], + aliases: { + id: "external_id", noteid: "external_id", externalid: "external_id", + body: "body", content: "body", text: "body", notes: "body", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || !r.body) return null; + r.author_id = ctx.userId; + r.entity_type = "case"; + r.entity_id = r.case_id; + return r; + }, + }, + // Status updates + { + key: "status_updates", + label: "Status updates → Status", + description: "Case status reports.", + table: "status_updates", + conflict: "external_id", + required: ["title", "body", "case_id"], + aliases: { + id: "external_id", statusid: "external_id", externalid: "external_id", + title: "title", subject: "title", + body: "body", content: "body", notes: "body", description: "body", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || !r.body) return null; + return r; + }, + }, +]; + +interface ImportResult { + total: number; + inserted: number; + skipped: number; + errors: string[]; +} + +function ImportPage() { + const { user } = useAuth(); + const [results, setResults] = useState>({}); + const ctx = useMemo(() => ({ + userId: user?.id ?? "", + clientByExt: new Map(), + caseByExt: new Map(), + contactByExt: new Map(), + homeownerByExt: new Map(), + invoiceByExt: new Map(), + clientByName: new Map(), + caseByNumber: new Map(), + }), [user?.id]); + + // Pre-load lookup caches from existing rows so subsequent imports can join + const ensureLookupsLoaded = async () => { + if (ctx.clientByExt.size === 0) { + const { data } = await supabase.from("clients").select("id, name, external_id"); + for (const r of data ?? []) { + if (r.external_id) ctx.clientByExt.set(r.external_id, r.id); + if (r.name) ctx.clientByName.set(r.name.toLowerCase(), r.id); + } + } + if (ctx.caseByExt.size === 0) { + const { data } = await supabase.from("cases").select("id, case_number, external_id"); + for (const r of data ?? []) { + if (r.external_id) ctx.caseByExt.set(r.external_id, r.id); + if (r.case_number) ctx.caseByNumber.set(r.case_number, r.id); + } + } + }; + + const handleFile = async (cfg: ImporterConfig, file: File) => { + if (!user?.id) return; + setResults((r) => ({ ...r, [cfg.key]: "running" })); + await ensureLookupsLoaded(); + + const parsed = await new Promise>>((resolve, reject) => { + Papa.parse>(file, { + header: true, + skipEmptyLines: true, + complete: resolve, + error: reject, + }); + }); + + const rawRows = parsed.data; + const headers = parsed.meta.fields ?? []; + // Build header → db column map + const headerMap: Record = {}; + for (const h of headers) { + const n = norm(h); + const target = cfg.aliases[n] ?? n; + headerMap[h] = target; + } + + const mapped: Row[] = []; + let skipped = 0; + const errors: string[] = []; + + for (const raw of rawRows) { + const row: Row = {}; + for (const [h, target] of Object.entries(headerMap)) { + const v = raw[h]; + if (v === undefined || v === "") continue; + row[target] = v; + } + // type coercions + for (const c of cfg.numeric ?? []) if (c in row) row[c] = toNum(row[c]); + for (const c of cfg.boolCols ?? []) if (c in row) row[c] = toBool(row[c]); + for (const c of cfg.dateCols ?? []) if (c in row) row[c] = toDate(row[c]); + for (const c of cfg.datetimeCols ?? []) if (c in row) row[c] = toDateTime(row[c]); + + // transform + const transformed = cfg.transform ? cfg.transform(row, ctx) : row; + if (!transformed) { skipped++; continue; } + + // required check + const missing = cfg.required.find((k) => !transformed[k] && transformed[k] !== 0); + if (missing) { skipped++; continue; } + + // attach created_by where not present + if (!transformed.created_by && !["time_entries", "expenses", "comments"].includes(cfg.table)) { + transformed.created_by = user.id; + } + mapped.push(transformed); + } + + if (mapped.length === 0) { + setResults((r) => ({ ...r, [cfg.key]: { total: rawRows.length, inserted: 0, skipped, errors: ["No valid rows after mapping. Check headers/required fields."] } })); + return; + } + + // Batch upsert in chunks of 200 + const chunkSize = 200; + let inserted = 0; + const inserted_rows: Row[] = []; + for (let i = 0; i < mapped.length; i += chunkSize) { + const chunk = mapped.slice(i, i + chunkSize); + // Filter out rows missing the conflict column for upsert; insert plain + const withConflict = chunk.filter((r) => r[cfg.conflict]); + const withoutConflict = chunk.filter((r) => !r[cfg.conflict]); + + if (withConflict.length > 0) { + const { data, error } = await supabase + .from(cfg.table as any) + .upsert(withConflict as any, { onConflict: cfg.conflict, ignoreDuplicates: false }) + .select("*"); + if (error) { + errors.push(`Batch ${i / chunkSize + 1} (upsert): ${error.message}`); + } else if (data) { + inserted += data.length; + inserted_rows.push(...data); + } + } + if (withoutConflict.length > 0) { + const { data, error } = await supabase + .from(cfg.table as any) + .insert(withoutConflict as any) + .select("*"); + if (error) { + errors.push(`Batch ${i / chunkSize + 1} (insert): ${error.message}`); + } else if (data) { + inserted += data.length; + inserted_rows.push(...data); + } + } + } + + if (cfg.postInsert) cfg.postInsert(inserted_rows, ctx); + + const summary = { total: rawRows.length, inserted, skipped, errors }; + setResults((r) => ({ ...r, [cfg.key]: summary })); + if (errors.length === 0) toast.success(`${cfg.label}: ${inserted} imported`); + else toast.error(`${cfg.label}: ${errors.length} batch error(s)`); + }; + + return ( +
+ + + + Headers are auto-mapped (snake_case + common aliases). Existing rows with a matching{" "} + external_id are updated. Import in order: + companies → people/lawyers → locations → cases → tasks/events/calls/time/expenses/invoices/notes/status. + + + +
+ {IMPORTERS.map((cfg) => { + const result = results[cfg.key]; + const running = result === "running"; + const r = typeof result === "object" ? result : null; + return ( + + +
+
+ + + {cfg.label} + +

{cfg.description}

+
+
+ { + const f = e.target.files?.[0]; + if (f) handleFile(cfg, f); + e.target.value = ""; + }} + disabled={running} + /> + +
+
+
+ {r && ( + +
+ {r.errors.length === 0 ? ( + + + {r.inserted} of {r.total} imported + + ) : ( + + + {r.inserted} imported, {r.errors.length} error(s) + + )} + {r.skipped > 0 && ( + {r.skipped} skipped (missing required fields) + )} +
+ {r.errors.length > 0 && ( +
+ {r.errors.slice(0, 3).map((e, i) =>
• {e}
)} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 71a08f7..911bbfc 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -17,6 +17,7 @@ const TABS = [ { to: "/settings/workflows", label: "Task workflows" }, { to: "/settings/smtp", label: "Email (SMTP)" }, { to: "/settings/imap", label: "Email (IMAP)" }, + { to: "/settings/import", label: "CSV import" }, ]; function SettingsLayout() { diff --git a/supabase/migrations/20260418011622_8f2085e8-1834-4de6-bab8-e87c8d7a0a41.sql b/supabase/migrations/20260418011622_8f2085e8-1834-4de6-bab8-e87c8d7a0a41.sql new file mode 100644 index 0000000..e576c8a --- /dev/null +++ b/supabase/migrations/20260418011622_8f2085e8-1834-4de6-bab8-e87c8d7a0a41.sql @@ -0,0 +1,60 @@ +-- Calendar events table +CREATE TABLE IF NOT EXISTS public.events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + case_id uuid REFERENCES public.cases(id) ON DELETE CASCADE, + client_id uuid REFERENCES public.clients(id) ON DELETE SET NULL, + title text NOT NULL, + description text, + location text, + start_at timestamptz NOT NULL, + end_at timestamptz, + all_day boolean NOT NULL DEFAULT false, + event_type text NOT NULL DEFAULT 'general', + external_id text, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS events_external_id_uidx ON public.events(external_id) WHERE external_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS events_case_id_idx ON public.events(case_id); +CREATE INDEX IF NOT EXISTS events_start_at_idx ON public.events(start_at); + +ALTER TABLE public.events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "events_select" ON public.events FOR SELECT TO authenticated + USING (case_id IS NULL OR public.can_access_case(case_id, auth.uid()) OR public.is_admin(auth.uid())); +CREATE POLICY "events_insert" ON public.events FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "events_update" ON public.events FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid() OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))); +CREATE POLICY "events_delete" ON public.events FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); + +CREATE TRIGGER events_set_updated_at BEFORE UPDATE ON public.events + FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- Add external_id to common imported tables for idempotent re-imports +ALTER TABLE public.cases ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.clients ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.homeowners ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.invoices ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.expenses ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.time_entries ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.call_logs ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS external_id text; +ALTER TABLE public.status_updates ADD COLUMN IF NOT EXISTS external_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS cases_external_id_uidx ON public.cases(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS clients_external_id_uidx ON public.clients(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS contacts_external_id_uidx ON public.contacts(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS homeowners_external_id_uidx ON public.homeowners(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS tasks_external_id_uidx ON public.tasks(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS invoices_external_id_uidx ON public.invoices(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS expenses_external_id_uidx ON public.expenses(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS time_entries_external_id_uidx ON public.time_entries(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS call_logs_external_id_uidx ON public.call_logs(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS documents_external_id_uidx ON public.documents(external_id) WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS status_updates_external_id_uidx ON public.status_updates(external_id) WHERE external_id IS NOT NULL; \ No newline at end of file