feature/IO-3702-ESPD-UI-AND-FIXES - Stage 2

This commit is contained in:
Dave
2026-05-26 11:28:49 -04:00
parent 122c0cebeb
commit 9a43910c9c
23 changed files with 260 additions and 445 deletions

View File

@@ -1,7 +1,7 @@
import { app } from "electron";
import log from "electron-log/main";
import Database from "better-sqlite3";
import crypto from "crypto";
import { DatabaseSync } from "node:sqlite";
import path from "path";
import type { RawJobDataObject } from "../decoder/decoder";
@@ -64,7 +64,7 @@ type IdentifiedItem = {
LinkText?: unknown;
};
let db: Database.Database | undefined;
let db: DatabaseSync | undefined;
function toNullableString(value: unknown): string | null {
if (value === null || value === undefined) return null;
@@ -84,15 +84,31 @@ function getDbPath(): string {
return path.join(userDataDir, "scrub-history.sqlite3");
}
function getDb(): Database.Database {
function runTransaction<T>(database: DatabaseSync, callback: () => T): T {
database.exec("BEGIN");
try {
const result = callback();
database.exec("COMMIT");
return result;
} catch (error) {
try {
database.exec("ROLLBACK");
} catch (rollbackError) {
log.error("[scrub-history-db] rollback failed", rollbackError);
}
throw error;
}
}
function getDb(): DatabaseSync {
if (db) return db;
const dbPath = getDbPath();
log.info(`[scrub-history-db] opening sqlite db at ${dbPath}`);
db = new Database(dbPath);
db = new DatabaseSync(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
@@ -182,7 +198,7 @@ export function insertScrubRun(params: {
? [params.identifiedItems as IdentifiedItem]
: [];
const insertTx = database.transaction(() => {
runTransaction(database, () => {
database
.prepare(
"INSERT INTO jobs (id, created_at, ownr_name, vehicle, claim_number, pdf_url, report_issue_url) VALUES (?, ?, ?, ?, ?, ?, ?)",
@@ -194,7 +210,7 @@ export function insertScrubRun(params: {
jobRow.vehicle,
jobRow.claim_number,
jobRow.pdf_url,
jobRow.report_issue_url,
jobRow.report_issue_url ?? null,
);
const stmt = database.prepare(
@@ -215,7 +231,6 @@ export function insertScrubRun(params: {
}
});
insertTx();
return { jobId: jobRow.id, createdAt, insertedResultsCount: items.length };
}
@@ -379,24 +394,26 @@ export function deleteScrubHistoryJob(jobId: string): { deletedJobs: number } {
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() };
return {
deletedJobs: runTransaction(database, () => {
const res = database
.prepare("DELETE FROM jobs WHERE id = ?")
.run(trimmed);
return Number(res.changes);
}),
};
}
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() };
return {
clearedJobs: runTransaction(database, () => {
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;
}),
};
}

View File

@@ -3,7 +3,6 @@ import log from "electron-log/main";
import _ from "lodash";
import deepLowerCaseKeys from "../../util/deepLowercaseKeys";
import errorTypeCheck from "../../util/errorTypeCheck";
import store from "../store/store";
import { DecodedLin, DecodedLinLine } from "./decode-lin.interface";
import { platform } from "@electron-toolkit/utils";
import { findFileCaseInsensitive } from "./decoder-utils";
@@ -51,7 +50,6 @@ const DecodeLin = async (
//AD2 will always have only 1 row.
//Commented lines have been cross referenced with existing partner fields.
const opCodeData = store.get("app.masterdata.opcodes"); //TODO: Type the op codes
const rawLinData: DecodedLinLine[] = rawDBFRecord.map((record) => {
const singleLineData: DecodedLinLine = deepLowerCaseKeys(

View File

@@ -4,7 +4,7 @@ import _ from "lodash";
import deepLowerCaseKeys from "../../util/deepLowercaseKeys";
import errorTypeCheck from "../../util/errorTypeCheck";
import { DecodedPfh } from "./decode-pfh.interface";
import { platform } from "os";
import { platform } from "@electron-toolkit/utils";
import { findFileCaseInsensitive } from "./decoder-utils";
const DecodePfh = async (
@@ -48,7 +48,7 @@ const DecodePfh = async (
const rawDBFRecord = await dbf.readRecords(1);
//AD2 will always have only 1 row.
//Commented lines have been cross referenced with existing partner fields.
//Commented lines have been cross-referenced with existing partner fields.
const rawPfhData: DecodedPfh = deepLowerCaseKeys(
_.pick(rawDBFRecord[0], [
@@ -76,7 +76,7 @@ const DecodePfh = async (
]),
);
//Apply business logic transfomrations.
//Apply business logic transformations.
//Standardize some of the numbers and divide by 100.

View File

@@ -37,11 +37,4 @@ const findFileCaseInsensitive = async (
return null;
};
const getFilePathWithoutExtension = (filePath: string): string => {
return path.join(
path.dirname(filePath),
path.basename(filePath, path.extname(filePath)),
);
};
export { findFileCaseInsensitive };

View File

@@ -1,6 +1,5 @@
import { platform } from "@electron-toolkit/utils";
import { UUID } from "crypto";
import { Notification, shell } from "electron";
import log from "electron-log/main";
import fs from "fs";
import _ from "lodash";
@@ -38,6 +37,7 @@ import { DecodedVeh } from "./decode-veh.interface";
import UploadEmsToS3 from "./emsbackup";
import getMainWindow from "../../util/getMainWindow";
import newWindow from "../../util/newWindow";
import { createNotification, showNotification } from "../util/notification";
async function ImportJob(filepath: string): Promise<void> {
const parsedFilePath = path.parse(filepath);
@@ -151,7 +151,7 @@ async function ImportJob(filepath: string): Promise<void> {
});
setAppProgressbar(-1);
const uploadNotification = new Notification({
const uploadNotification = createNotification({
title: "Job Scrubbed",
body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}.`,
actions: [
@@ -175,12 +175,10 @@ async function ImportJob(filepath: string): Promise<void> {
uploadNotification.show();
} catch (error) {
log.error("Error encountered while decoding job. ", errorTypeCheck(error));
const uploadNotificationFailure = new Notification({
showNotification({
title: "Job Scrub Failure",
body: errorTypeCheck(error).message, //TODO: Remove after debug.
});
uploadNotificationFailure.show();
}
}
@@ -203,6 +201,7 @@ export interface RawJobDataObject
DecodedPfp {
vehicleid?: UUID;
shopid: UUID;
source_system?: string | null;
}
export interface AvailableJobSchema {

View File

@@ -5,8 +5,6 @@ import errorTypeCheck from "../../util/errorTypeCheck";
import fs from "fs";
import path from "path";
import stream from "stream";
import { getTokenFromRenderer } from "../graphql/graphql-client";
import store from "../store/store";
async function UploadEmsToS3({
extensionlessFilePath,

View File

@@ -164,6 +164,7 @@ export interface ESJobObject extends Omit<
| "v_model_desc"
| "shopid"
| "est_system"
| "id_pro_nam"
// Object fields
| "owner"

View File

@@ -1,5 +1,4 @@
import axios, { AxiosError } from "axios";
import { BrowserWindow } from "electron";
import log from "electron-log";
import { autoUpdater } from "electron-updater";
import { promises as fsPromises } from "fs";
@@ -8,8 +7,21 @@ import { RawJobDataObject } from "../decoder/decoder";
import store from "../store/store";
import ipcTypes from "../../util/ipcTypes.json";
import { insertScrubRun } from "../db/scrub-history-db";
import { Notification } from "electron/main";
import getMainWindow from "../../util/getMainWindow";
import { showNotification } from "../util/notification";
function getErrorMessage(responseMessage: string | undefined): string | null {
if (!responseMessage) {
return null;
}
try {
const parsedResponse = JSON.parse(responseMessage) as { message?: string };
return parsedResponse.message ?? null;
} catch {
return responseMessage;
}
}
// Function to write job object to logs subfolder
async function writeJobToLogsFolder(job, fileName): Promise<string> {
@@ -75,13 +87,11 @@ async function ScrubEstimate({
const esApiKey = store.get("settings.esApiKey") as string;
if (!esApiKey || esApiKey.trim() === "") {
const notificationError = new Notification({
showNotification({
title: "No API Key Set",
body: "You must have a valid and active API key set in order to scrub estimates.",
});
notificationError.show();
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
"You must have a valid and active API key set in order to scrub estimates.",
@@ -127,7 +137,6 @@ async function ScrubEstimate({
report_issue_url:
typeof report_issue_url === "string" ? report_issue_url : null,
});
const mainWindow = BrowserWindow.getAllWindows()[0];
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.historyUpdated);
}
@@ -158,27 +167,24 @@ async function ScrubEstimate({
: responseData
? JSON.stringify(responseData)
: undefined;
const scrubErrorMessage = getErrorMessage(responseMessage);
if (status === 400) {
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
JSON.parse(responseMessage).message ||
scrubErrorMessage ||
"Error encountered sending estimate to Estimate Scrubber.",
});
} else if (status === 401) {
const notificationError = new Notification({
showNotification({
title: "Error scrubbing estimate",
body:
JSON.parse(responseMessage).message ||
"Authentication with Estimate Scrubber failed.",
scrubErrorMessage || "Authentication with Estimate Scrubber failed.",
});
notificationError.show();
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
JSON.parse(responseMessage).message ||
"Authentication with Estimate Scrubber failed.",
scrubErrorMessage || "Authentication with Estimate Scrubber failed.",
});
}
return "Error: Unable to scrub estimate.";

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain } from "electron";
import { ipcMain } from "electron";
import log from "electron-log/main";
import { GraphQLClient, RequestMiddleware } from "graphql-request";
import errorTypeCheck from "../../util/errorTypeCheck.js";

View File

@@ -13,7 +13,7 @@ test("Basic Electron app compilation.", async () => {
// Wait for the first BrowserWindow to open
// and return its Page object
const window = await electronApp.firstWindow();
await electronApp.firstWindow();
// close app
await electronApp.close();
});

View File

@@ -1,5 +1,5 @@
import { is, optimizer, platform } from "@electron-toolkit/utils";
import Sentry from "@sentry/electron/main";
import * as Sentry from "@sentry/electron/main";
import {
app,
BrowserWindow,
@@ -105,7 +105,7 @@ function createWindow(): void {
},
});
const template: Electron.MenuItemConstructorOptions[] = [
const template = [
// { role: 'appMenu' }
// @ts-ignore
...(isMac
@@ -387,7 +387,7 @@ function createWindow(): void {
: [{ role: "close" }]),
],
},
];
] as Electron.MenuItemConstructorOptions[];
const menu: Electron.Menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);

View File

@@ -27,10 +27,15 @@ import newWindow from "../../util/newWindow";
const logIpcMessages = (): void => {
Object.keys(ipcTypes.toMain).forEach((key) => {
const messageType = ipcTypes.toMain[key];
const originalHandler = ipcMain.listeners(messageType)?.[0];
if (originalHandler) {
if (typeof messageType !== "string") {
return;
}
const originalHandlers = ipcMain.listeners(messageType);
if (originalHandlers.length > 0) {
ipcMain.removeAllListeners(messageType);
}
ipcMain.on(messageType, (event, payload) => {
log.info(
`%c[IPC Main]%c${messageType}`,
@@ -38,9 +43,7 @@ const logIpcMessages = (): void => {
"color: green",
payload,
);
if (originalHandler) {
originalHandler(event, payload);
}
originalHandlers.forEach((handler) => handler(event, payload));
});
});
};
@@ -102,7 +105,7 @@ ipcMain.on(ipcTypes.toMain.updates.download, () => {
});
});
ipcMain.on(ipcTypes.toMain.openExternal, async (event, url: string) => {
ipcMain.on(ipcTypes.toMain.openExternal, async (_event, url: string) => {
newWindow(url);
});

View File

@@ -0,0 +1,26 @@
import { Notification, type NotificationConstructorOptions } from "electron";
import log from "electron-log/main";
function createNotification(
options: NotificationConstructorOptions,
context = options.title,
): Notification {
const notification = new Notification(options);
notification.on("failed", (_event, error) => {
log.warn(`Notification failed${context ? ` (${context})` : ""}:`, error);
});
return notification;
}
function showNotification(
options: NotificationConstructorOptions,
context?: string,
): Notification {
const notification = createNotification(options, context);
notification.show();
return notification;
}
export { createNotification, showNotification };

View File

@@ -1,5 +1,4 @@
import chokidar, { FSWatcher } from "chokidar";
import { Notification } from "electron";
import log from "electron-log/main";
import fs from "fs";
import path from "path";
@@ -8,6 +7,7 @@ import ipcTypes from "../../util/ipcTypes.json";
import ImportJob from "../decoder/decoder";
import store from "../store/store";
import getMainWindow from "../../util/getMainWindow";
import { showNotification } from "../util/notification";
import { setWatcherTrayStatus } from "../util/trayStatus";
let watcher: FSWatcher | null;
let watcherReady = false;
@@ -36,11 +36,11 @@ async function StartWatcher(
if (filePaths.length === 0) {
if (notifyOnNoPaths) {
new Notification({
showNotification({
//TODO: Add Translations
title: "Watcher cannot start",
body: "Please set the appropriate file paths and try again.",
}).show();
});
}
log.warn("Cannot start watcher. No valid file paths set.", {
configuredFilePaths,
@@ -139,10 +139,10 @@ function onWatcherReady(): void {
if (watcher) {
const mainWindow = getMainWindow();
watcherReady = true;
new Notification({
showNotification({
title: "Watcher Started",
body: "Newly exported estimates will be automatically uploaded.",
}).show();
});
log.info("Confirmed watched paths:", watcher.getWatched());
setWatcherTrayStatus(true);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.started);
@@ -160,10 +160,10 @@ async function StopWatcher(): Promise<boolean> {
setWatcherTrayStatus(false);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
new Notification({
showNotification({
title: "Watcher Stopped",
body: "Estimates will not be automatically uploaded.",
}).show();
});
return true;
}
return false;

View File

@@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import log from "electron-log/renderer";
import type { RootState } from "./redux-store";
interface AppState {
export interface AppState {
value: number;
watcher: {
started: boolean;

View File

@@ -1,10 +1,8 @@
import type { TypedUseSelectorHook } from "react-redux";
import { useDispatch, useSelector, useStore } from "react-redux";
import type { AppDispatch, AppStore, RootState } from "./redux-store";
import store from "./redux-store";
//Use these custom hooks to access the Redux store from your component with type safety.
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = useDispatch.withTypes<AppDispatch>(); // Ex
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppStore: () => AppStore = useStore;

View File

@@ -39,7 +39,10 @@ const useCountDown = (
interval,
timer.current.timeLeft || Infinity,
);
if (timer.current.lastInterval && ts - timer.current.lastInterval >= localInterval) {
if (
timer.current.lastInterval &&
ts - timer.current.lastInterval >= localInterval
) {
timer.current.lastInterval += localInterval;
setTimeLeft((timeLeft) => {
timer.current.timeLeft = timeLeft - localInterval;

View File

@@ -9,9 +9,9 @@ import {
// uri: import.meta.env.VITE_GRAPHQL_URL,
// });
const middlewares = [];
const middlewares: ApolloLink[] = [];
const client: ApolloClient<any> = new ApolloClient({
const client: ApolloClient = new ApolloClient({
link: ApolloLink.from(middlewares),
cache: new InMemoryCache(),
defaultOptions: {
@@ -21,9 +21,6 @@ const client: ApolloClient<any> = new ApolloClient({
query: {
fetchPolicy: "network-only",
},
mutate: {
errorPolicy: "none",
},
},
});