Added events CSV importer

X-Lovable-Edit-ID: edt-343a7c67-736a-4839-924f-36fe3f3c49d5
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:18:47 +00:00
co-authored by renee-png
7 changed files with 900 additions and 0 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -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",
+99
View File
@@ -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
+21
View File
@@ -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,
+717
View File
@@ -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<string, any>;
interface ImporterConfig {
key: string;
label: string;
description: string;
table: string;
conflict: string; // column for upsert
// Maps normalized csv header → db column
aliases: Record<string, string>;
// 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<string, string>;
caseByExt: Map<string, string>;
contactByExt: Map<string, string>;
homeownerByExt: Map<string, string>;
invoiceByExt: Map<string, string>;
// Name → uuid lookups (for files that reference by name only)
clientByName: Map<string, string>;
caseByNumber: Map<string, string>;
}
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<Record<string, ImportResult | "running">>({});
const ctx = useMemo<ImportCtx>(() => ({
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<Papa.ParseResult<Record<string, string>>>((resolve, reject) => {
Papa.parse<Record<string, string>>(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<string, string> = {};
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 (
<div className="space-y-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Headers are auto-mapped (snake_case + common aliases). Existing rows with a matching{" "}
<code className="text-xs">external_id</code> are updated. <strong>Import in order</strong>:
companies → people/lawyers → locations → cases → tasks/events/calls/time/expenses/invoices/notes/status.
</AlertDescription>
</Alert>
<div className="grid gap-3">
{IMPORTERS.map((cfg) => {
const result = results[cfg.key];
const running = result === "running";
const r = typeof result === "object" ? result : null;
return (
<Card key={cfg.key}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
{cfg.label}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">{cfg.description}</p>
</div>
<div className="shrink-0">
<input
type="file"
accept=".csv,text/csv"
id={`file-${cfg.key}`}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(cfg, f);
e.target.value = "";
}}
disabled={running}
/>
<Button asChild size="sm" variant="outline" disabled={running}>
<label htmlFor={`file-${cfg.key}`} className="cursor-pointer">
{running ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1.5" />}
{running ? "Importing…" : "Upload CSV"}
</label>
</Button>
</div>
</div>
</CardHeader>
{r && (
<CardContent className="pt-0">
<div className="flex items-center gap-2 text-xs flex-wrap">
{r.errors.length === 0 ? (
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
{r.inserted} of {r.total} imported
</Badge>
) : (
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20">
<AlertCircle className="h-3 w-3 mr-1" />
{r.inserted} imported, {r.errors.length} error(s)
</Badge>
)}
{r.skipped > 0 && (
<span className="text-muted-foreground">{r.skipped} skipped (missing required fields)</span>
)}
</div>
{r.errors.length > 0 && (
<div className="mt-2 text-xs text-destructive space-y-1">
{r.errors.slice(0, 3).map((e, i) => <div key={i}>• {e}</div>)}
</div>
)}
</CardContent>
)}
</Card>
);
})}
</div>
</div>
);
}
+1
View File
@@ -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() {
@@ -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;