Local history for scrubbing.
This commit is contained in:
586
package-lock.json
generated
586
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
||||
"@sentry/electron": "^7.8.0",
|
||||
"@sentry/vite-plugin": "^5.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.8.3",
|
||||
|
||||
@@ -24,8 +24,11 @@ interface ScrubResponse {
|
||||
|
||||
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
|
||||
try {
|
||||
const { esApiKey, rawJob } = JSON.parse(event.body || '{}') as ScrubRequest;
|
||||
|
||||
const {
|
||||
//esApiKey,
|
||||
rawJob,
|
||||
} = JSON.parse(event.body || '{}') as ScrubRequest;
|
||||
const esApiKey = event.headers['x-api-key'] || '';
|
||||
//await uploadJobToHasura(rawJob, esApiKey);
|
||||
// Transform the raw job object to ES format
|
||||
const estimate: ESJobObject = await transformJobForEstimateScrubber(rawJob);
|
||||
|
||||
386
src/main/db/scrub-history-db.ts
Normal file
386
src/main/db/scrub-history-db.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import Database from "better-sqlite3";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { RawJobDataObject } from "../decoder/decoder";
|
||||
|
||||
export type ScrubHistoryJobRow = {
|
||||
id: string;
|
||||
created_at: number;
|
||||
ownr_name: string;
|
||||
vehicle: string;
|
||||
claim_number: string;
|
||||
pdf_url: string | null;
|
||||
};
|
||||
|
||||
export type ScrubHistoryScrubResultRow = {
|
||||
id: number;
|
||||
created_at: number;
|
||||
job_id: string;
|
||||
anchor: string | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
left_text: string | null;
|
||||
right_text: string | null;
|
||||
linktext: string | null;
|
||||
};
|
||||
|
||||
export type ScrubHistoryItem = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
ownrName: string;
|
||||
vehicle: string;
|
||||
claimNumber: string;
|
||||
pdfUrl: string | null;
|
||||
results: Array<{
|
||||
createdAt: number;
|
||||
anchor: string | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
left: string | null;
|
||||
right: string | null;
|
||||
linktext: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ScrubHistoryPage = {
|
||||
items: ScrubHistoryItem[];
|
||||
totalJobs: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalResults: number;
|
||||
lastProcessed: number | null;
|
||||
};
|
||||
|
||||
type IdentifiedItem = {
|
||||
Anchor?: unknown;
|
||||
Category?: unknown;
|
||||
SubCategory?: unknown;
|
||||
L?: unknown;
|
||||
R?: unknown;
|
||||
LinkText?: unknown;
|
||||
};
|
||||
|
||||
let db: Database.Database | undefined;
|
||||
|
||||
function toNullableString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function getDbPath(): string {
|
||||
const userDataDir = app.getPath("userData");
|
||||
return path.join(userDataDir, "scrub-history.sqlite3");
|
||||
}
|
||||
|
||||
function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dbPath = getDbPath();
|
||||
log.info(`[scrub-history-db] opening sqlite db at ${dbPath}`);
|
||||
db = new Database(dbPath);
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
ownr_name TEXT NOT NULL,
|
||||
vehicle TEXT NOT NULL,
|
||||
claim_number TEXT NOT NULL,
|
||||
pdf_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scrub_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
anchor TEXT,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
left_text TEXT,
|
||||
right_text TEXT,
|
||||
linktext TEXT,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_scrub_results_job_id ON scrub_results(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scrub_results_created_at ON scrub_results(created_at DESC);
|
||||
`);
|
||||
|
||||
// Lightweight migrations for existing installs
|
||||
const ensureJobsColumn = (columnName: string, definition: string): void => {
|
||||
const cols = db!.prepare("PRAGMA table_info(jobs)").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const hasCol = cols.some((c) => c.name === columnName);
|
||||
if (!hasCol) {
|
||||
log.info(`[scrub-history-db] migrating jobs: adding ${columnName}`);
|
||||
db!.exec(`ALTER TABLE jobs ADD COLUMN ${definition}`);
|
||||
}
|
||||
};
|
||||
|
||||
ensureJobsColumn("pdf_url", "pdf_url TEXT");
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function buildJobRow(
|
||||
job: RawJobDataObject,
|
||||
createdAt: number,
|
||||
pdfUrl: string | null,
|
||||
): ScrubHistoryJobRow {
|
||||
const ownrName = `${job.ownr_fn ?? ""} ${job.ownr_ln ?? ""}`.trim();
|
||||
const vehicle =
|
||||
`${job.v_model_yr ?? ""} ${job.v_make_desc ?? ""} ${job.v_model_desc ?? ""}`.trim();
|
||||
const claimNumber = `${job.clm_no ?? ""}`.trim();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
created_at: createdAt,
|
||||
ownr_name: ownrName || "Unknown",
|
||||
vehicle: vehicle || "Unknown",
|
||||
claim_number: claimNumber || "Unknown",
|
||||
pdf_url: pdfUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertScrubRun(params: {
|
||||
job: RawJobDataObject;
|
||||
identifiedItems: unknown;
|
||||
pdfUrl: string | null;
|
||||
}): { jobId: string; createdAt: number; insertedResultsCount: number } {
|
||||
const database = getDb();
|
||||
const createdAt = Date.now();
|
||||
const jobRow = buildJobRow(params.job, createdAt, params.pdfUrl);
|
||||
|
||||
const items: IdentifiedItem[] = Array.isArray(params.identifiedItems)
|
||||
? (params.identifiedItems as IdentifiedItem[])
|
||||
: params.identifiedItems
|
||||
? [params.identifiedItems as IdentifiedItem]
|
||||
: [];
|
||||
|
||||
const insertTx = database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
"INSERT INTO jobs (id, created_at, ownr_name, vehicle, claim_number, pdf_url) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
jobRow.id,
|
||||
jobRow.created_at,
|
||||
jobRow.ownr_name,
|
||||
jobRow.vehicle,
|
||||
jobRow.claim_number,
|
||||
jobRow.pdf_url,
|
||||
);
|
||||
|
||||
const stmt = database.prepare(
|
||||
"INSERT INTO scrub_results (created_at, job_id, anchor, category, subcategory, left_text, right_text, linktext) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
stmt.run(
|
||||
createdAt,
|
||||
jobRow.id,
|
||||
toNullableString(item.Anchor),
|
||||
toNullableString(item.Category),
|
||||
toNullableString(item.SubCategory),
|
||||
toNullableString(item.L),
|
||||
toNullableString(item.R),
|
||||
toNullableString(item.LinkText),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertTx();
|
||||
return { jobId: jobRow.id, createdAt, insertedResultsCount: items.length };
|
||||
}
|
||||
|
||||
export function getScrubHistory(): ScrubHistoryItem[] {
|
||||
const database = getDb();
|
||||
|
||||
const jobs = database
|
||||
.prepare(
|
||||
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url FROM jobs ORDER BY created_at DESC",
|
||||
)
|
||||
.all() as ScrubHistoryJobRow[];
|
||||
|
||||
if (jobs.length === 0) return [];
|
||||
|
||||
const results = database
|
||||
.prepare(
|
||||
"SELECT id, created_at, job_id, anchor, category, subcategory, left_text, right_text, linktext FROM scrub_results ORDER BY created_at DESC, id DESC",
|
||||
)
|
||||
.all() as ScrubHistoryScrubResultRow[];
|
||||
|
||||
const resultsByJobId = new Map<string, ScrubHistoryItem["results"]>();
|
||||
for (const r of results) {
|
||||
const bucket = resultsByJobId.get(r.job_id) ?? [];
|
||||
bucket.push({
|
||||
createdAt: r.created_at,
|
||||
anchor: r.anchor,
|
||||
category: r.category,
|
||||
subcategory: r.subcategory,
|
||||
left: r.left_text,
|
||||
right: r.right_text,
|
||||
linktext: r.linktext,
|
||||
});
|
||||
resultsByJobId.set(r.job_id, bucket);
|
||||
}
|
||||
|
||||
return jobs.map((j) => ({
|
||||
id: j.id,
|
||||
createdAt: j.created_at,
|
||||
ownrName: j.ownr_name,
|
||||
vehicle: j.vehicle,
|
||||
claimNumber: j.claim_number,
|
||||
pdfUrl: j.pdf_url ?? null,
|
||||
results: resultsByJobId.get(j.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePageParams(params: { page: number; pageSize: number }): {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
} {
|
||||
const page = Number.isFinite(params.page) ? Math.floor(params.page) : 1;
|
||||
const pageSize = Number.isFinite(params.pageSize)
|
||||
? Math.floor(params.pageSize)
|
||||
: 10;
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(100, Math.max(1, pageSize));
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
return { page: safePage, pageSize: safePageSize, offset };
|
||||
}
|
||||
|
||||
export function getScrubHistoryPage(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): ScrubHistoryPage {
|
||||
const database = getDb();
|
||||
const { page, pageSize, offset } = normalizePageParams(params);
|
||||
|
||||
const totalJobsRow = database
|
||||
.prepare("SELECT COUNT(1) as c FROM jobs")
|
||||
.get() as { c: number };
|
||||
const totalJobs = totalJobsRow?.c ?? 0;
|
||||
|
||||
const totalResultsRow = database
|
||||
.prepare("SELECT COUNT(1) as c FROM scrub_results")
|
||||
.get() as { c: number };
|
||||
const totalResults = totalResultsRow?.c ?? 0;
|
||||
|
||||
const lastProcessedRow = database
|
||||
.prepare("SELECT MAX(created_at) as m FROM jobs")
|
||||
.get() as { m: number | null };
|
||||
const lastProcessed = lastProcessedRow?.m ?? null;
|
||||
|
||||
if (totalJobs === 0) {
|
||||
return {
|
||||
items: [],
|
||||
totalJobs,
|
||||
page,
|
||||
pageSize,
|
||||
totalResults,
|
||||
lastProcessed,
|
||||
};
|
||||
}
|
||||
|
||||
const jobs = database
|
||||
.prepare(
|
||||
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(pageSize, offset) as ScrubHistoryJobRow[];
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
totalJobs,
|
||||
page,
|
||||
pageSize,
|
||||
totalResults,
|
||||
lastProcessed,
|
||||
};
|
||||
}
|
||||
|
||||
const jobIds = jobs.map((j) => j.id);
|
||||
const placeholders = jobIds.map(() => "?").join(",");
|
||||
|
||||
const results = database
|
||||
.prepare(
|
||||
`SELECT id, created_at, job_id, anchor, category, subcategory, left_text, right_text, linktext FROM scrub_results WHERE job_id IN (${placeholders}) ORDER BY created_at DESC, id DESC`,
|
||||
)
|
||||
.all(...jobIds) as ScrubHistoryScrubResultRow[];
|
||||
|
||||
const resultsByJobId = new Map<string, ScrubHistoryItem["results"]>();
|
||||
for (const r of results) {
|
||||
const bucket = resultsByJobId.get(r.job_id) ?? [];
|
||||
bucket.push({
|
||||
createdAt: r.created_at,
|
||||
anchor: r.anchor,
|
||||
category: r.category,
|
||||
subcategory: r.subcategory,
|
||||
left: r.left_text,
|
||||
right: r.right_text,
|
||||
linktext: r.linktext,
|
||||
});
|
||||
resultsByJobId.set(r.job_id, bucket);
|
||||
}
|
||||
|
||||
return {
|
||||
items: jobs.map((j) => ({
|
||||
id: j.id,
|
||||
createdAt: j.created_at,
|
||||
ownrName: j.ownr_name,
|
||||
vehicle: j.vehicle,
|
||||
claimNumber: j.claim_number,
|
||||
pdfUrl: j.pdf_url ?? null,
|
||||
results: resultsByJobId.get(j.id) ?? [],
|
||||
})),
|
||||
totalJobs,
|
||||
page,
|
||||
pageSize,
|
||||
totalResults,
|
||||
lastProcessed,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteScrubHistoryJob(jobId: string): { deletedJobs: number } {
|
||||
const database = getDb();
|
||||
const trimmed = jobId.trim();
|
||||
if (!trimmed) return { deletedJobs: 0 };
|
||||
|
||||
const tx = database.transaction(() => {
|
||||
const res = database.prepare("DELETE FROM jobs WHERE id = ?").run(trimmed);
|
||||
return res.changes;
|
||||
});
|
||||
|
||||
return { deletedJobs: tx() };
|
||||
}
|
||||
|
||||
export function clearScrubHistory(): { clearedJobs: number } {
|
||||
const database = getDb();
|
||||
|
||||
const tx = database.transaction(() => {
|
||||
const countBefore = database
|
||||
.prepare("SELECT COUNT(1) as c FROM jobs")
|
||||
.get() as { c: number };
|
||||
database.prepare("DELETE FROM jobs").run();
|
||||
return countBefore?.c ?? 0;
|
||||
});
|
||||
|
||||
return { clearedJobs: tx() };
|
||||
}
|
||||
@@ -50,33 +50,33 @@ async function ImportJob(filepath: string): Promise<void> {
|
||||
|
||||
//The below all end up returning parts of the job object.
|
||||
//Some of them return additional info - e.g. owner or vehicle record data at both the job and corresponding table level.
|
||||
setAppProgressbar(0.1);
|
||||
const env: DecodedEnv = await DecodeEnv(extensionlessFilePath);
|
||||
setAppProgressbar(0.15);
|
||||
const ad1: DecodedAd1 = await DecodeAD1(extensionlessFilePath);
|
||||
setAppProgressbar(0.2);
|
||||
const ad2: DecodedAD2 = await DecodeAD2(extensionlessFilePath);
|
||||
setAppProgressbar(0.25);
|
||||
const veh: DecodedVeh = await DecodeVeh(extensionlessFilePath);
|
||||
setAppProgressbar(0.3);
|
||||
const lin: DecodedLin = await DecodeLin(extensionlessFilePath);
|
||||
setAppProgressbar(0.35);
|
||||
const pfh: DecodedPfh = await DecodePfh(extensionlessFilePath);
|
||||
setAppProgressbar(0.4);
|
||||
const pfl: DecodedPfl = await DecodePfl(extensionlessFilePath);
|
||||
setAppProgressbar(0.45);
|
||||
const pft: DecodedPft = await DecodePft(extensionlessFilePath);
|
||||
setAppProgressbar(0.5);
|
||||
const env: DecodedEnv = await DecodeEnv(extensionlessFilePath);
|
||||
setAppProgressbar(0.1);
|
||||
const ad1: DecodedAd1 = await DecodeAD1(extensionlessFilePath);
|
||||
setAppProgressbar(0.15);
|
||||
const ad2: DecodedAD2 = await DecodeAD2(extensionlessFilePath);
|
||||
setAppProgressbar(0.2);
|
||||
const veh: DecodedVeh = await DecodeVeh(extensionlessFilePath);
|
||||
setAppProgressbar(0.25);
|
||||
const lin: DecodedLin = await DecodeLin(extensionlessFilePath);
|
||||
setAppProgressbar(0.3);
|
||||
const pfh: DecodedPfh = await DecodePfh(extensionlessFilePath);
|
||||
setAppProgressbar(0.35);
|
||||
const pfl: DecodedPfl = await DecodePfl(extensionlessFilePath);
|
||||
setAppProgressbar(0.4);
|
||||
const pft: DecodedPft = await DecodePft(extensionlessFilePath);
|
||||
setAppProgressbar(0.45);
|
||||
const pfm: DecodedPfm = await DecodePfm(extensionlessFilePath);
|
||||
setAppProgressbar(0.55);
|
||||
setAppProgressbar(0.5);
|
||||
const pfo: DecodedPfo = await DecodePfo(extensionlessFilePath); // TODO: This will be the `cieca_pfo` object
|
||||
setAppProgressbar(0.6);
|
||||
setAppProgressbar(0.55);
|
||||
const stl: DecodedStl = await DecodeStl(extensionlessFilePath); // TODO: This will be the `cieca_stl` object
|
||||
setAppProgressbar(0.65);
|
||||
setAppProgressbar(0.6);
|
||||
const ttl: DecodedTtl = await DecodeTtl(extensionlessFilePath);
|
||||
setAppProgressbar(0.7);
|
||||
setAppProgressbar(0.65);
|
||||
const pfp: DecodedPfp = await DecodePfp(extensionlessFilePath);
|
||||
setAppProgressbar(0.75);
|
||||
setAppProgressbar(0.7);
|
||||
|
||||
const jobObjectUncleaned: RawJobDataObject = {
|
||||
...env,
|
||||
@@ -97,7 +97,6 @@ async function ImportJob(filepath: string): Promise<void> {
|
||||
|
||||
// Replace owner information with claimant information if necessary
|
||||
const jobObject = ReplaceOwnerInfoWithClaimant(jobObjectUncleaned);
|
||||
setAppProgressbar(0.8);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// Save jobObject to a timestamped JSON file
|
||||
@@ -132,33 +131,14 @@ async function ImportJob(filepath: string): Promise<void> {
|
||||
issupplement: false,
|
||||
jobid: null,
|
||||
};
|
||||
setAppProgressbar(0.85);
|
||||
setAppProgressbar(0.73);
|
||||
|
||||
console.log("Available Job record to upload;", newAvailableJob);
|
||||
|
||||
setAppProgressbar(0.95);
|
||||
|
||||
setAppProgressbar(-1);
|
||||
const uploadNotification = new Notification({
|
||||
title: "Job Imported",
|
||||
//subtitle: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}`,
|
||||
body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}. Click to view.`,
|
||||
actions: [{ text: "View Job", type: "button" }],
|
||||
});
|
||||
uploadNotification.on("click", () => {
|
||||
shell.openExternal(
|
||||
`${
|
||||
store.get("app.isTest")
|
||||
? import.meta.env.VITE_FE_URL_TEST
|
||||
: import.meta.env.VITE_FE_URL
|
||||
}/manage/available`,
|
||||
);
|
||||
});
|
||||
uploadNotification.show();
|
||||
|
||||
//Scrub the estimate
|
||||
const scrubResults = await ScrubEstimate({ job: jobObject });
|
||||
|
||||
setAppProgressbar(0.95);
|
||||
const esApiKey = store.get("settings.esApiKey") as string;
|
||||
UploadEmsToS3({
|
||||
extensionlessFilePath,
|
||||
@@ -167,11 +147,24 @@ async function ImportJob(filepath: string): Promise<void> {
|
||||
clm_no: jobObject.clm_no ?? "",
|
||||
ownr_ln: jobObject.ownr_ln ?? "",
|
||||
});
|
||||
console.log("Got past the job upload.");
|
||||
setAppProgressbar(-1);
|
||||
|
||||
const uploadNotification = new Notification({
|
||||
title: "Job Scrubbed",
|
||||
//subtitle: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}`,
|
||||
body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}. Click to view.`,
|
||||
actions: [{ text: "View Job", type: "button" }],
|
||||
});
|
||||
uploadNotification.on("click", () => {
|
||||
if (scrubResults?.data?.resultPDFUrl) {
|
||||
shell.openExternal(scrubResults.data.resultPDFUrl);
|
||||
}
|
||||
});
|
||||
uploadNotification.show();
|
||||
} catch (error) {
|
||||
log.error("Error encountered while decoding job. ", errorTypeCheck(error));
|
||||
const uploadNotificationFailure = new Notification({
|
||||
title: "Job Upload Failure",
|
||||
title: "Job Scrub Failure",
|
||||
body: errorTypeCheck(error).message, //TODO: Remove after debug.
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ async function UploadEmsToS3({
|
||||
ownr_ln,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
headers: { "x-api-key": esApiKey },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { RawJobDataObject } from "../decoder/decoder";
|
||||
import store from "../store/store";
|
||||
import ipcTypes from "../../util/ipcTypes.json";
|
||||
import { insertScrubRun } from "../db/scrub-history-db";
|
||||
|
||||
// Function to write job object to logs subfolder
|
||||
async function writeJobToLogsFolder(job, fileName): Promise<string> {
|
||||
@@ -84,13 +85,34 @@ async function ScrubEstimate({
|
||||
}
|
||||
|
||||
// Send raw job to Lambda - transformation happens server-side
|
||||
const result = await axios.post(estimateScrubberUrl, {
|
||||
esApiKey,
|
||||
rawJob: job, // Changed from 'estimate' to 'rawJob'
|
||||
});
|
||||
const result = await axios.post(
|
||||
estimateScrubberUrl,
|
||||
{
|
||||
esApiKey,
|
||||
rawJob: job, // Changed from 'estimate' to 'rawJob'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-api-key": esApiKey,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { resultPDFUrl, reportIssueUrl, identified_item } =
|
||||
result?.data ?? {};
|
||||
const { resultPDFUrl, identified_item } = result?.data ?? {};
|
||||
|
||||
try {
|
||||
insertScrubRun({
|
||||
job,
|
||||
identifiedItems: identified_item.slice(1, identified_item.length), // Remove first item which is the result metadata
|
||||
pdfUrl: typeof resultPDFUrl === "string" ? resultPDFUrl : null,
|
||||
});
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.historyUpdated);
|
||||
}
|
||||
} catch (dbError) {
|
||||
log.error("Failed to persist scrub history:", dbError);
|
||||
}
|
||||
|
||||
// log.log("Estimate Scrubber Result:", result.data, resultPDFUrl);
|
||||
// const b = BrowserWindow.getAllWindows()[0];
|
||||
@@ -112,20 +134,29 @@ async function ScrubEstimate({
|
||||
return resultPDFUrl;
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
log.error("Error while scrubbing estimate:", error, err.stack);
|
||||
log.error("Error while scrubbing estimate:", err.message, err.stack);
|
||||
log.error("Error Response Data:", err.response?.data);
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
|
||||
if (error.status === 400) {
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
|
||||
const status = err.response?.status;
|
||||
const responseData = err.response?.data;
|
||||
const responseMessage =
|
||||
typeof responseData === "string"
|
||||
? responseData
|
||||
: responseData
|
||||
? JSON.stringify(responseData)
|
||||
: undefined;
|
||||
|
||||
if (status === 400) {
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
|
||||
message:
|
||||
error.response?.data ||
|
||||
responseMessage ||
|
||||
"Error encountered sending estimate to Estimate Scrubber.",
|
||||
});
|
||||
} else if (error.status === 401) {
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
|
||||
} else if (status === 401) {
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
|
||||
message:
|
||||
err.response?.data || "Authentication with Estimate Scrubber failed."ta,
|
||||
responseMessage || "Authentication with Estimate Scrubber failed.",
|
||||
});
|
||||
}
|
||||
return "Error: Unable to scrub estimate.";
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
ipcMainHandleAuthStateChanged,
|
||||
ipMainHandleResetPassword,
|
||||
} from "./ipcMainHandler.user";
|
||||
import {
|
||||
ScrubHistoryClearAll,
|
||||
ScrubHistoryDeleteJob,
|
||||
ScrubHistoryGetAll,
|
||||
} from "./ipcMainHandler.scrubHistory";
|
||||
|
||||
// Log all IPC messages and their payloads
|
||||
const logIpcMessages = (): void => {
|
||||
@@ -56,6 +61,11 @@ ipcMain.on(ipcTypes.toMain.test, () =>
|
||||
ipcMain.on(ipcTypes.toMain.authStateChanged, ipcMainHandleAuthStateChanged);
|
||||
ipcMain.on(ipcTypes.toMain.user.resetPassword, ipMainHandleResetPassword);
|
||||
|
||||
// Scrub History Handlers
|
||||
ipcMain.handle(ipcTypes.toMain.scrubHistory.getAll, ScrubHistoryGetAll);
|
||||
ipcMain.handle(ipcTypes.toMain.scrubHistory.deleteJob, ScrubHistoryDeleteJob);
|
||||
ipcMain.handle(ipcTypes.toMain.scrubHistory.clearAll, ScrubHistoryClearAll);
|
||||
|
||||
// Add debug handlers if in development
|
||||
if (import.meta.env.DEV) {
|
||||
log.debug("[IPC Debug Functions] Adding Debug Handlers");
|
||||
|
||||
67
src/main/ipc/ipcMainHandler.scrubHistory.ts
Normal file
67
src/main/ipc/ipcMainHandler.scrubHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import ipcTypes from "../../util/ipcTypes.json";
|
||||
import {
|
||||
clearScrubHistory,
|
||||
deleteScrubHistoryJob,
|
||||
getScrubHistory,
|
||||
getScrubHistoryPage,
|
||||
} from "../db/scrub-history-db";
|
||||
|
||||
export async function ScrubHistoryGetAll(
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
): Promise<
|
||||
ReturnType<typeof getScrubHistory> | ReturnType<typeof getScrubHistoryPage>
|
||||
> {
|
||||
try {
|
||||
if (
|
||||
params &&
|
||||
(params.page !== undefined || params.pageSize !== undefined)
|
||||
) {
|
||||
return getScrubHistoryPage({
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
});
|
||||
}
|
||||
return getScrubHistory();
|
||||
} catch (error) {
|
||||
log.error("[ScrubHistoryGetAll] failed", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function notifyHistoryUpdated(): void {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.historyUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ScrubHistoryDeleteJob(
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
jobId: string,
|
||||
): Promise<{ ok: boolean; deletedJobs: number }> {
|
||||
try {
|
||||
const { deletedJobs } = deleteScrubHistoryJob(jobId);
|
||||
if (deletedJobs > 0) notifyHistoryUpdated();
|
||||
return { ok: true, deletedJobs };
|
||||
} catch (error) {
|
||||
log.error("[ScrubHistoryDeleteJob] failed", error);
|
||||
return { ok: false, deletedJobs: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function ScrubHistoryClearAll(): Promise<{
|
||||
ok: boolean;
|
||||
clearedJobs: number;
|
||||
}> {
|
||||
try {
|
||||
const { clearedJobs } = clearScrubHistory();
|
||||
if (clearedJobs > 0) notifyHistoryUpdated();
|
||||
return { ok: true, clearedJobs };
|
||||
} catch (error) {
|
||||
log.error("[ScrubHistoryClearAll] failed", error);
|
||||
return { ok: false, clearedJobs: 0 };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge } from "electron";
|
||||
import { BrowserWindow, contextBridge, shell } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import "electron-log/preload";
|
||||
import store from "../main/store/store";
|
||||
@@ -6,10 +6,21 @@ import store from "../main/store/store";
|
||||
// Custom APIs for renderer
|
||||
interface Api {
|
||||
isTest: () => boolean;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const api: Api = {
|
||||
isTest: (): boolean => store.get("app.isTest") || false,
|
||||
openExternal: async (url: string): Promise<void> => {
|
||||
const pdfWindow = new BrowserWindow({
|
||||
webPreferences: {
|
||||
plugins: true, // Enable PDF viewing
|
||||
},
|
||||
});
|
||||
|
||||
pdfWindow.loadURL(url);
|
||||
pdfWindow.focus();
|
||||
},
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -11,145 +12,289 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
theme,
|
||||
List,
|
||||
Avatar,
|
||||
Divider,
|
||||
} from "antd";
|
||||
import { FC } from "react";
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SettingOutlined,
|
||||
EyeOutlined,
|
||||
RightOutlined,
|
||||
DatabaseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import ipcTypes from "../../../../util/ipcTypes.json";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Placeholder data for recently scrubbed estimates
|
||||
const placeholderEstimates = [
|
||||
{
|
||||
id: "EST-2026-001",
|
||||
fileName: "estimate_john_doe.pdf",
|
||||
scrubbedAt: "2026-01-12T10:30:00",
|
||||
status: "completed",
|
||||
itemsProcessed: 24,
|
||||
},
|
||||
{
|
||||
id: "EST-2026-002",
|
||||
fileName: "estimate_jane_smith.pdf",
|
||||
scrubbedAt: "2026-01-12T09:15:00",
|
||||
status: "completed",
|
||||
itemsProcessed: 18,
|
||||
},
|
||||
{
|
||||
id: "EST-2026-003",
|
||||
fileName: "estimate_acme_corp.pdf",
|
||||
scrubbedAt: "2026-01-11T16:45:00",
|
||||
status: "completed",
|
||||
itemsProcessed: 32,
|
||||
},
|
||||
{
|
||||
id: "EST-2026-004",
|
||||
fileName: "estimate_bob_johnson.pdf",
|
||||
scrubbedAt: "2026-01-11T14:20:00",
|
||||
status: "completed",
|
||||
itemsProcessed: 15,
|
||||
},
|
||||
{
|
||||
id: "EST-2026-005",
|
||||
fileName: "estimate_tech_solutions.pdf",
|
||||
scrubbedAt: "2026-01-11T11:00:00",
|
||||
status: "completed",
|
||||
itemsProcessed: 27,
|
||||
},
|
||||
];
|
||||
const categoryConfig = {
|
||||
"Administrative Items": { color: "blue", priority: 1, icon: "📎" },
|
||||
"Rates Issues": { color: "blue", priority: 2, icon: "💵" },
|
||||
"MPI Guidelines Items": { color: "blue", priority: 3, icon: "📋" },
|
||||
"Estimator Recommendations": { color: "blue", priority: 4, icon: "✅" },
|
||||
"Estimate Parts Found": { color: "blue", priority: 5, icon: "🔧" },
|
||||
"All Parts Found": { color: "blue", priority: 6, icon: "🔧" },
|
||||
} as const;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Estimate",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
responsive: ["md"] as const,
|
||||
render: (text: string, record: (typeof placeholderEstimates)[0]) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong>{text}</Text>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{record.fileName}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
dataIndex: "fileName",
|
||||
key: "fileName",
|
||||
responsive: ["xs"] as const,
|
||||
render: (text: string, record: (typeof placeholderEstimates)[0]) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong>{record.id}</Text>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Scrubbed",
|
||||
dataIndex: "scrubbedAt",
|
||||
key: "scrubbedAt",
|
||||
responsive: ["sm"] as const,
|
||||
render: (text: string) => {
|
||||
const date = new Date(text);
|
||||
return (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text>{date.toLocaleDateString()}</Text>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{date.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Items",
|
||||
dataIndex: "itemsProcessed",
|
||||
key: "itemsProcessed",
|
||||
responsive: ["lg"] as const,
|
||||
render: (value: number) => <Text strong>{value}</Text>,
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
responsive: ["sm"] as const,
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
color={status === "completed" ? "success" : "processing"}
|
||||
icon={<CheckCircleOutlined />}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{status === "completed" ? "Done" : "Processing"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "action",
|
||||
width: 100,
|
||||
render: () => (
|
||||
<Button type="link" icon={<EyeOutlined />} size="small">
|
||||
View
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
type ScrubHistoryItem = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
ownrName: string;
|
||||
vehicle: string;
|
||||
claimNumber: string;
|
||||
pdfUrl: string | null;
|
||||
results: Array<ScrubHistoryResultItem>;
|
||||
};
|
||||
|
||||
type ScrubHistoryResultItem = {
|
||||
createdAt: number;
|
||||
anchor: string | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
left: string | null;
|
||||
right: string | null;
|
||||
linktext: string | null;
|
||||
};
|
||||
|
||||
type ScrubHistoryPage = {
|
||||
items: ScrubHistoryItem[];
|
||||
totalJobs: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalResults: number;
|
||||
lastProcessed: number | null;
|
||||
};
|
||||
|
||||
function isScrubHistoryPage(value: unknown): value is ScrubHistoryPage {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Partial<ScrubHistoryPage>;
|
||||
return Array.isArray(v.items) && typeof v.totalJobs === "number";
|
||||
}
|
||||
|
||||
const Home: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = theme.useToken();
|
||||
const ipcRenderer = window.electron.ipcRenderer;
|
||||
|
||||
const [history, setHistory] = useState<ScrubHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [totalJobs, setTotalJobs] = useState<number>(0);
|
||||
const [totalResults, setTotalResults] = useState<number>(0);
|
||||
const [lastProcessed, setLastProcessed] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (page: number, size: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = (await ipcRenderer.invoke(
|
||||
ipcTypes.toMain.scrubHistory.getAll,
|
||||
{ page, pageSize: size },
|
||||
)) as unknown;
|
||||
|
||||
if (isScrubHistoryPage(response)) {
|
||||
setHistory(response.items);
|
||||
setTotalJobs(response.totalJobs);
|
||||
setTotalResults(response.totalResults);
|
||||
setLastProcessed(response.lastProcessed);
|
||||
|
||||
if (
|
||||
response.items.length === 0 &&
|
||||
response.totalJobs > 0 &&
|
||||
page > 1
|
||||
) {
|
||||
setCurrentPage(page - 1);
|
||||
}
|
||||
} else if (Array.isArray(response)) {
|
||||
setHistory(response as ScrubHistoryItem[]);
|
||||
setTotalJobs((response as ScrubHistoryItem[]).length);
|
||||
setTotalResults(
|
||||
(response as ScrubHistoryItem[]).reduce(
|
||||
(acc, job) => acc + (job.results?.length ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
setLastProcessed(
|
||||
(response as ScrubHistoryItem[])[0]?.createdAt ?? null,
|
||||
);
|
||||
} else {
|
||||
setHistory([]);
|
||||
setTotalJobs(0);
|
||||
setTotalResults(0);
|
||||
setLastProcessed(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ipcRenderer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh(currentPage, pageSize).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const handler = () => {
|
||||
refresh(currentPage, pageSize).catch(() => undefined);
|
||||
};
|
||||
|
||||
ipcRenderer.on(ipcTypes.toRenderer.scrub.historyUpdated, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(
|
||||
ipcTypes.toRenderer.scrub.historyUpdated,
|
||||
handler,
|
||||
);
|
||||
};
|
||||
}, [ipcRenderer, refresh, currentPage, pageSize]);
|
||||
|
||||
const totalItemsScrubbed = totalResults;
|
||||
|
||||
const deleteJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.deleteJob, jobId);
|
||||
await refresh(currentPage, pageSize);
|
||||
},
|
||||
[ipcRenderer, refresh, currentPage, pageSize],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.clearAll);
|
||||
setCurrentPage(1);
|
||||
await refresh(1, pageSize);
|
||||
}, [ipcRenderer, refresh, pageSize]);
|
||||
|
||||
const jobColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Claim #",
|
||||
dataIndex: "claimNumber",
|
||||
key: "claimNumber",
|
||||
render: (text: string) => <Text strong>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: "Owner",
|
||||
dataIndex: "ownrName",
|
||||
key: "ownrName",
|
||||
},
|
||||
{
|
||||
title: "Vehicle",
|
||||
dataIndex: "vehicle",
|
||||
key: "vehicle",
|
||||
responsive: ["md" as const],
|
||||
},
|
||||
{
|
||||
title: "Scrubbed",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
responsive: ["sm" as const],
|
||||
render: (value: number) => {
|
||||
const date = new Date(value);
|
||||
return (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text>{date.toLocaleDateString()}</Text>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{date.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "PDF",
|
||||
key: "pdf",
|
||||
width: 90,
|
||||
render: (_: unknown, record: ScrubHistoryItem) => (
|
||||
<Button
|
||||
type="link"
|
||||
disabled={!record.pdfUrl}
|
||||
onClick={() => {
|
||||
if (!record.pdfUrl) return;
|
||||
window.api.openExternal(record.pdfUrl);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Items",
|
||||
key: "items",
|
||||
width: 90,
|
||||
align: "right" as const,
|
||||
render: (_: unknown, record: ScrubHistoryItem) => (
|
||||
<Text strong>{record.results?.length ?? 0}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: () => (
|
||||
<Tag
|
||||
color="success"
|
||||
icon={<CheckCircleOutlined />}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
Done
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_: unknown, record: ScrubHistoryItem) => (
|
||||
<Popconfirm
|
||||
title="Delete this job?"
|
||||
description="This will also delete its scrub results."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => deleteJob(record.id)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[deleteJob],
|
||||
);
|
||||
|
||||
const resultColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Subcategory",
|
||||
dataIndex: "subcategory",
|
||||
key: "subcategory",
|
||||
width: "20%",
|
||||
},
|
||||
{ title: "Item", dataIndex: "left", key: "left", width: "20%" },
|
||||
{ title: "Description", dataIndex: "right", key: "right", width: "50%" },
|
||||
{
|
||||
title: "Link",
|
||||
dataIndex: "linktext",
|
||||
key: "linktext",
|
||||
render: (text: string | null, record: ScrubHistoryResultItem) =>
|
||||
record.linktext ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (record.anchor) window.api.openExternal(record.anchor);
|
||||
}}
|
||||
disabled={!record.anchor}
|
||||
type="link"
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
) : null,
|
||||
width: "10%",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
|
||||
@@ -161,7 +306,7 @@ const Home: FC = () => {
|
||||
{/* Header */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap="small">
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Dashboard
|
||||
{t("dashboard.labels.dashboard")}
|
||||
</Title>
|
||||
<Button
|
||||
type="default"
|
||||
@@ -185,10 +330,10 @@ const Home: FC = () => {
|
||||
<Statistic
|
||||
title={
|
||||
<span style={{ color: "rgba(255,255,255,0.85)" }}>
|
||||
Total Estimates
|
||||
{t("dashboard.labels.total_estimates")}
|
||||
</span>
|
||||
}
|
||||
value={placeholderEstimates.length}
|
||||
value={totalJobs}
|
||||
prefix={<FileTextOutlined style={{ color: "#fff" }} />}
|
||||
valueStyle={{ color: "#fff", fontWeight: 600 }}
|
||||
/>
|
||||
@@ -204,13 +349,10 @@ const Home: FC = () => {
|
||||
<Statistic
|
||||
title={
|
||||
<span style={{ color: "rgba(255,255,255,0.85)" }}>
|
||||
Items Scrubbed
|
||||
{t("dashboard.labels.scrub_results")}
|
||||
</span>
|
||||
}
|
||||
value={placeholderEstimates.reduce(
|
||||
(acc, est) => acc + est.itemsProcessed,
|
||||
0,
|
||||
)}
|
||||
value={totalItemsScrubbed}
|
||||
prefix={<CheckCircleOutlined style={{ color: "#fff" }} />}
|
||||
valueStyle={{ color: "#fff", fontWeight: 600 }}
|
||||
/>
|
||||
@@ -226,12 +368,14 @@ const Home: FC = () => {
|
||||
<Statistic
|
||||
title={
|
||||
<span style={{ color: "rgba(255,255,255,0.85)" }}>
|
||||
Last Processed
|
||||
{t("dashboard.labels.last_processed")}
|
||||
</span>
|
||||
}
|
||||
value={new Date(
|
||||
placeholderEstimates[0].scrubbedAt,
|
||||
).toLocaleTimeString()}
|
||||
value={
|
||||
lastProcessed
|
||||
? new Date(lastProcessed).toLocaleTimeString()
|
||||
: "—"
|
||||
}
|
||||
prefix={<ClockCircleOutlined style={{ color: "#fff" }} />}
|
||||
valueStyle={{ color: "#fff", fontWeight: 600 }}
|
||||
/>
|
||||
@@ -244,19 +388,23 @@ const Home: FC = () => {
|
||||
bordered={false}
|
||||
title={
|
||||
<Flex align="center" gap="small">
|
||||
<FileTextOutlined style={{ fontSize: "20px" }} />
|
||||
<span>Recently Scrubbed Estimates</span>
|
||||
<DatabaseOutlined style={{ fontSize: "20px" }} />
|
||||
<span>{t("dashboard.labels.estimate_scrub_history")}</span>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RightOutlined />}
|
||||
iconPlacement="end"
|
||||
onClick={() => alert("View all estimates - coming soon!")}
|
||||
<Popconfirm
|
||||
title="Clear all scrub history?"
|
||||
description="This will delete all jobs and scrub results on this computer."
|
||||
okText="Clear All"
|
||||
cancelText="Cancel"
|
||||
onConfirm={clearAll}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
<Button danger disabled={history.length === 0}>
|
||||
{t("dashboard.actions.clear_all")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
styles={{
|
||||
header: {
|
||||
@@ -267,12 +415,104 @@ const Home: FC = () => {
|
||||
{/* Table view for larger screens */}
|
||||
<div style={{ display: "block" }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={placeholderEstimates}
|
||||
columns={jobColumns}
|
||||
dataSource={history}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 5, showSizeChanger: false }}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: totalJobs,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onChange: (nextPage: number, nextSize: number) => {
|
||||
setCurrentPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
style={{ overflow: "auto" }}
|
||||
expandable={{
|
||||
expandedRowRender: (record: ScrubHistoryItem) => {
|
||||
const grouped = (record.results ?? []).reduce(
|
||||
(acc, item) => {
|
||||
const key = item.category ?? "Uncategorized";
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ScrubHistoryResultItem[]>,
|
||||
);
|
||||
|
||||
const groups = Object.entries(grouped)
|
||||
.map(([category, items]) => ({ category, items }))
|
||||
.sort((a, b) => {
|
||||
const aCfg =
|
||||
categoryConfig[
|
||||
a.category as keyof typeof categoryConfig
|
||||
];
|
||||
const bCfg =
|
||||
categoryConfig[
|
||||
b.category as keyof typeof categoryConfig
|
||||
];
|
||||
|
||||
const aPriority =
|
||||
aCfg?.priority ?? Number.POSITIVE_INFINITY;
|
||||
const bPriority =
|
||||
bCfg?.priority ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
return a.category.localeCompare(b.category);
|
||||
});
|
||||
|
||||
return (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size="middle"
|
||||
style={{ width: "100%", display: "flex" }}
|
||||
>
|
||||
{groups.map(({ category, items }) => {
|
||||
const cfg =
|
||||
categoryConfig[
|
||||
category as keyof typeof categoryConfig
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Space size="small">
|
||||
<Text strong>
|
||||
{cfg?.icon ? `${cfg.icon} ` : ""}
|
||||
{category}
|
||||
</Text>
|
||||
<Tag color={cfg?.color ?? "default"}>
|
||||
{items.length}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Table
|
||||
columns={resultColumns}
|
||||
dataSource={items}
|
||||
rowKey={(row, idx) =>
|
||||
`${category}-${row.createdAt}-${idx}`
|
||||
}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
rowExpandable: (record: ScrubHistoryItem) =>
|
||||
(record.results?.length ?? 0) > 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -10,6 +10,7 @@ declare global {
|
||||
interface Window {
|
||||
api: {
|
||||
isTest: () => boolean;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"toMain": {
|
||||
"test": "toMain_test",
|
||||
"authStateChanged": "toMain_authStateChanged",
|
||||
"scrubHistory": {
|
||||
"getAll": "toMain_scrubHistory_getAll",
|
||||
"deleteJob": "toMain_scrubHistory_deleteJob",
|
||||
"clearAll": "toMain_scrubHistory_clearAll"
|
||||
},
|
||||
"debug": {
|
||||
"decodeEstimate": "toMain_debug_decodeEstimate"
|
||||
},
|
||||
@@ -56,7 +61,8 @@
|
||||
"polling": "toRenderer_watcher_polling"
|
||||
},
|
||||
"scrub": {
|
||||
"scrubError": "toRenderer_scrubError"
|
||||
"scrubError": "toRenderer_scrubError",
|
||||
"historyUpdated": "toRenderer_scrub_historyUpdated"
|
||||
},
|
||||
"updates": {
|
||||
"checking": "toRenderer_updates_checking",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"resetpassword": "Reset Password"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"clear_all": "Clear All"
|
||||
},
|
||||
"labels": {
|
||||
"dashboard": "Dashboard",
|
||||
"estimate_scrub_history": "Scrub History",
|
||||
"last_processed": "Last Processed at",
|
||||
"scrub_results": "Scrub Results",
|
||||
"total_estimates": "Total Estimates"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"errorboundary": "Uh oh - we've hit an error.",
|
||||
"notificationtitle": "Error Encountered"
|
||||
@@ -31,7 +43,9 @@
|
||||
"labels": {
|
||||
"actions": "Actions",
|
||||
"addPaintScalePath": "Add Paint Scale Path",
|
||||
"config": "Configuration",
|
||||
"emsOutFilePath": "EMS Out File Path (Parts Order, etc.)",
|
||||
"esApiKey": "Estimate Scrubber API Key",
|
||||
"invalidPath": "Path not set or invalid",
|
||||
"paintScalePath": "Paint Scale Path",
|
||||
"paintScaleSettingsInput": "BSMS To Paint Scale",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<babeledit_project be_version="2.7.1" version="1.2">
|
||||
<babeledit_project version="1.2" be_version="2.7.1">
|
||||
<!--
|
||||
|
||||
BabelEdit project file
|
||||
@@ -112,6 +112,99 @@
|
||||
</folder_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>dashboard</name>
|
||||
<children>
|
||||
<folder_node>
|
||||
<name>actions</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>clear_all</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>labels</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>dashboard</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>estimate_scrub_history</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>last_processed</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>scrub_results</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>total_estimates</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>errors</name>
|
||||
<children>
|
||||
@@ -281,6 +374,19 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>config</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>emsOutFilePath</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -294,6 +400,19 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>esApiKey</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>invalidPath</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
|
||||
Reference in New Issue
Block a user