Local history for scrubbing.

This commit is contained in:
Patrick Fic
2026-02-25 10:50:46 -08:00
parent e97f644373
commit 4915e05ac9
15 changed files with 1413 additions and 479 deletions

586
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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);

View 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() };
}

View File

@@ -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.
});

View File

@@ -71,7 +71,7 @@ async function UploadEmsToS3({
ownr_ln,
},
{
headers: {},
headers: { "x-api-key": esApiKey },
},
);

View File

@@ -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.";

View File

@@ -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");

View 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 };
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -10,6 +10,7 @@ declare global {
interface Window {
api: {
isTest: () => boolean;
openExternal: (url: string) => Promise<void>;
};
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>