Added MyCase CSV import
X-Lovable-Edit-ID: edt-2d30a454-da8e-47dd-ba46-fadbb1de1158 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -53,6 +53,9 @@ interface ImportCtx {
|
||||
// Name → uuid lookups (for files that reference by name only)
|
||||
clientByName: Map<string, string>;
|
||||
caseByNumber: Map<string, string>;
|
||||
caseByTitle: Map<string, string>;
|
||||
userByName: Map<string, string>;
|
||||
userByEmail: Map<string, string>;
|
||||
// Optional fallback client id (e.g. for address-only locations CSV)
|
||||
defaultClientId?: string | null;
|
||||
// Selected contact group (for unified contacts importer)
|
||||
@@ -324,28 +327,92 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
{
|
||||
key: "time_entries",
|
||||
label: "Time entries → Time",
|
||||
description: "Billable time records.",
|
||||
description: "Billable time records. Supports MyCase headers: Date, Activity, Time, Rate, Flat rate, Total, Description, User, Case Name, Invoice, Nonbillable, MyCase ID.",
|
||||
table: "time_entries",
|
||||
conflict: "external_id",
|
||||
required: ["case_id", "description", "hours"],
|
||||
aliases: {
|
||||
id: "external_id", timeid: "external_id", externalid: "external_id",
|
||||
id: "external_id", timeid: "external_id", externalid: "external_id", mycaseid: "external_id",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
description: "description", notes: "description",
|
||||
hours: "hours", duration: "hours",
|
||||
casename: "_casename", case: "_casename", matter: "_casename", mattername: "_casename",
|
||||
description: "description", notes: "description", activity: "_activity",
|
||||
hours: "hours", duration: "hours", time: "_time",
|
||||
rate: "hourly_rate", hourlyrate: "hourly_rate",
|
||||
billable: "billable",
|
||||
flatrate: "_flatrate", flatfee: "_flatrate",
|
||||
total: "_total", amount: "_total",
|
||||
billable: "billable", nonbillable: "_nonbillable", nonbill: "_nonbillable",
|
||||
date: "work_date", workdate: "work_date",
|
||||
user: "_username", username: "_username", attorney: "_username", staff: "_username", timekeeper: "_username",
|
||||
invoice: "_invoice", invoicenumber: "_invoice",
|
||||
},
|
||||
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;
|
||||
// Resolve case
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
if (!r.case_id) return null;
|
||||
r.user_id = ctx.userId;
|
||||
if (r.hours == null) return null;
|
||||
|
||||
// Resolve hours from "Time" (e.g. "1.5", "1:30", "01:30:00") if needed
|
||||
if (r.hours == null && r._time != null) {
|
||||
const s = String(r._time).trim();
|
||||
const colon = s.match(/^(\d+):(\d{1,2})(?::(\d{1,2}))?$/);
|
||||
if (colon) {
|
||||
const h = Number(colon[1]) + Number(colon[2]) / 60 + (colon[3] ? Number(colon[3]) / 3600 : 0);
|
||||
r.hours = isNaN(h) ? null : h;
|
||||
} else {
|
||||
const n = toNum(s);
|
||||
if (n != null) r.hours = n;
|
||||
}
|
||||
}
|
||||
delete r._time;
|
||||
|
||||
// Derive hourly_rate from total/flat rate when missing
|
||||
const total = toNum(r._total);
|
||||
const flat = toNum(r._flatrate);
|
||||
if (r.hourly_rate == null && r.hours && total != null && r.hours > 0) {
|
||||
r.hourly_rate = total / r.hours;
|
||||
}
|
||||
if (r.hourly_rate == null && flat != null && r.hours && r.hours > 0) {
|
||||
r.hourly_rate = flat / r.hours;
|
||||
}
|
||||
if (r.hourly_rate == null) r.hourly_rate = 0;
|
||||
delete r._total; delete r._flatrate;
|
||||
|
||||
// Billable: explicit `nonbillable` truthy → false; otherwise default true
|
||||
if (r._nonbillable != null) {
|
||||
r.billable = !toBool(r._nonbillable);
|
||||
} else if (r.billable == null) {
|
||||
r.billable = true;
|
||||
}
|
||||
delete r._nonbillable;
|
||||
|
||||
// Description: prefer Description, fall back to Activity
|
||||
if (!r.description && r._activity) r.description = String(r._activity).trim();
|
||||
if (r.description && r._activity && !String(r.description).startsWith(String(r._activity))) {
|
||||
// keep description as-is; activity is supplemental
|
||||
}
|
||||
delete r._activity;
|
||||
|
||||
// User (timekeeper) lookup; fall back to current user so RLS insert succeeds
|
||||
let uid: string | undefined;
|
||||
if (r._username) {
|
||||
const key = String(r._username).toLowerCase().trim();
|
||||
uid = ctx.userByName.get(key) || ctx.userByEmail.get(key);
|
||||
}
|
||||
r.user_id = uid || ctx.userId;
|
||||
delete r._username;
|
||||
|
||||
// Invoice number is informational — we don't link it (would require invoice lookup).
|
||||
delete r._invoice;
|
||||
|
||||
if (r.hours == null || r.hours <= 0) return null;
|
||||
if (!r.description) return null;
|
||||
return r;
|
||||
},
|
||||
},
|
||||
@@ -477,6 +544,9 @@ function ImportPage() {
|
||||
invoiceByExt: new Map(),
|
||||
clientByName: new Map(),
|
||||
caseByNumber: new Map(),
|
||||
caseByTitle: new Map(),
|
||||
userByName: new Map(),
|
||||
userByEmail: new Map(),
|
||||
defaultClientId: null,
|
||||
defaultContactType: null,
|
||||
}), [user?.id]);
|
||||
@@ -525,15 +595,24 @@ function ImportPage() {
|
||||
}
|
||||
ctx.caseByExt.clear();
|
||||
ctx.caseByNumber.clear();
|
||||
const cases = await fetchAllPaginated<{ id: string; case_number: string | null; external_id: string | null }>(
|
||||
ctx.caseByTitle.clear();
|
||||
const cases = await fetchAllPaginated<{ id: string; case_number: string | null; title: string | null; external_id: string | null }>(
|
||||
"cases",
|
||||
"id, case_number, external_id",
|
||||
"id, case_number, title, external_id",
|
||||
);
|
||||
for (const r of cases) {
|
||||
if (r.external_id) ctx.caseByExt.set(r.external_id, r.id);
|
||||
if (r.case_number) ctx.caseByNumber.set(r.case_number, r.id);
|
||||
if (r.title) ctx.caseByTitle.set(r.title.toLowerCase().trim(), r.id);
|
||||
}
|
||||
console.info(`[import] lookups loaded: clients=${ctx.clientByExt.size}/${ctx.clientByName.size} cases=${ctx.caseByExt.size}/${ctx.caseByNumber.size}`);
|
||||
ctx.userByName.clear();
|
||||
ctx.userByEmail.clear();
|
||||
const { data: profs } = await supabase.from("profiles").select("id, full_name, email");
|
||||
for (const p of (profs ?? []) as { id: string; full_name: string | null; email: string | null }[]) {
|
||||
if (p.full_name) ctx.userByName.set(p.full_name.toLowerCase().trim(), p.id);
|
||||
if (p.email) ctx.userByEmail.set(p.email.toLowerCase().trim(), p.id);
|
||||
}
|
||||
console.info(`[import] lookups loaded: clients=${ctx.clientByExt.size}/${ctx.clientByName.size} cases=${ctx.caseByExt.size}/${ctx.caseByNumber.size}/${ctx.caseByTitle.size} users=${ctx.userByName.size}`);
|
||||
};
|
||||
|
||||
const handleFile = async (cfg: ImporterConfig, file: File) => {
|
||||
|
||||
Reference in New Issue
Block a user