feature/IO-3702-ESPD-UI-AND-FIXES - Stage 1
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
|
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
|
||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'ca-central-1' });
|
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'ca-central-1' });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
|
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios from 'axios';
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import { GraphQLClient, gql } from 'graphql-request';
|
import { gql, GraphQLClient } from 'graphql-request';
|
||||||
import { ESJobObject, RawJobDataObject } from '../../../shared/types';
|
import { ESJobObject, RawJobDataObject } from '../../../shared/types';
|
||||||
import { transformJobForEstimateScrubber } from '../lib/transformEstimate';
|
import { transformJobForEstimateScrubber } from '../lib/transformEstimate';
|
||||||
import { getVehicleType } from '../lib/vehicleTypes/vehicleType';
|
import { getVehicleType } from '../lib/vehicleTypes/vehicleType';
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const DecodeStl = async (
|
|||||||
return singleLineData;
|
return singleLineData;
|
||||||
});
|
});
|
||||||
|
|
||||||
//Apply business logic transfomrations.
|
//Apply business logic transformations.
|
||||||
//We don't have an inspection date, we instead have `date_estimated`
|
//We don't have an inspection date, we instead have `date_estimated`
|
||||||
|
|
||||||
return { cieca_stl: { data: rawStlData } };
|
return { cieca_stl: { data: rawStlData } };
|
||||||
|
|||||||
@@ -591,7 +591,20 @@ app.whenReady().then(async () => {
|
|||||||
isKeepAliveLaunch = true;
|
isKeepAliveLaunch = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
StartWatcher();
|
const runWatcherOnStartup = store.get("settings.runWatcherOnStartup") as
|
||||||
|
| boolean
|
||||||
|
| undefined;
|
||||||
|
const configuredWatcherPaths =
|
||||||
|
(store.get("settings.filepaths") as string[] | undefined) || [];
|
||||||
|
|
||||||
|
if (runWatcherOnStartup !== false && configuredWatcherPaths.length > 0) {
|
||||||
|
StartWatcher({ notifyOnNoPaths: false }).catch((error) => {
|
||||||
|
log.error(
|
||||||
|
"Error starting watcher on app startup:",
|
||||||
|
errorTypeCheck(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
//The update itself will run when the bodyshop record is queried to know what release channel to use.
|
//The update itself will run when the bodyshop record is queried to know what release channel to use.
|
||||||
openMainWindow();
|
openMainWindow();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { ipcMain } from "electron";
|
|||||||
import log from "electron-log/main";
|
import log from "electron-log/main";
|
||||||
import { autoUpdater } from "electron-updater";
|
import { autoUpdater } from "electron-updater";
|
||||||
import ipcTypes from "../../util/ipcTypes.json";
|
import ipcTypes from "../../util/ipcTypes.json";
|
||||||
import { StartWatcher, StopWatcher } from "../watcher/watcher";
|
import {
|
||||||
|
IsWatcherStarted,
|
||||||
|
StartWatcher,
|
||||||
|
StopWatcher,
|
||||||
|
} from "../watcher/watcher";
|
||||||
import {
|
import {
|
||||||
ScrubHistoryClearAll,
|
ScrubHistoryClearAll,
|
||||||
ScrubHistoryDeleteJob,
|
ScrubHistoryDeleteJob,
|
||||||
@@ -89,6 +93,8 @@ ipcMain.on(ipcTypes.toMain.watcher.stop, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(ipcTypes.toMain.watcher.status, () => IsWatcherStarted());
|
||||||
|
|
||||||
ipcMain.on(ipcTypes.toMain.updates.download, () => {
|
ipcMain.on(ipcTypes.toMain.updates.download, () => {
|
||||||
log.info("Download update requested from renderer.");
|
log.info("Download update requested from renderer.");
|
||||||
autoUpdater.downloadUpdate().catch((error) => {
|
autoUpdater.downloadUpdate().catch((error) => {
|
||||||
|
|||||||
@@ -10,17 +10,41 @@ import store from "../store/store";
|
|||||||
import getMainWindow from "../../util/getMainWindow";
|
import getMainWindow from "../../util/getMainWindow";
|
||||||
import { setWatcherTrayStatus } from "../util/trayStatus";
|
import { setWatcherTrayStatus } from "../util/trayStatus";
|
||||||
let watcher: FSWatcher | null;
|
let watcher: FSWatcher | null;
|
||||||
|
let watcherReady = false;
|
||||||
|
|
||||||
async function StartWatcher(): Promise<boolean> {
|
type StartWatcherOptions = {
|
||||||
const filePaths: string[] = store.get("settings.filepaths") || [];
|
notifyOnNoPaths?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getValidWatcherPaths(filePaths: string[]): string[] {
|
||||||
|
return filePaths.filter((filePath) => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath) && fs.statSync(filePath).isDirectory();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`Unable to validate watcher path ${filePath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function StartWatcher(
|
||||||
|
options: StartWatcherOptions = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { notifyOnNoPaths = true } = options;
|
||||||
|
const configuredFilePaths: string[] = store.get("settings.filepaths") || [];
|
||||||
|
const filePaths = getValidWatcherPaths(configuredFilePaths);
|
||||||
|
|
||||||
if (filePaths.length === 0) {
|
if (filePaths.length === 0) {
|
||||||
new Notification({
|
if (notifyOnNoPaths) {
|
||||||
//TODO: Add Translations
|
new Notification({
|
||||||
title: "Watcher cannot start",
|
//TODO: Add Translations
|
||||||
body: "Please set the appropriate file paths and try again.",
|
title: "Watcher cannot start",
|
||||||
}).show();
|
body: "Please set the appropriate file paths and try again.",
|
||||||
log.warn("Cannot start watcher. No file paths set.");
|
}).show();
|
||||||
|
}
|
||||||
|
log.warn("Cannot start watcher. No valid file paths set.", {
|
||||||
|
configuredFilePaths,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +52,8 @@ async function StartWatcher(): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
log.info("Trying to close watcher - it already existed.");
|
log.info("Trying to close watcher - it already existed.");
|
||||||
await watcher.close();
|
await watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
watcherReady = false;
|
||||||
|
|
||||||
log.info("Watcher closed successfully!");
|
log.info("Watcher closed successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,6 +105,7 @@ async function StartWatcher(): Promise<boolean> {
|
|||||||
// })
|
// })
|
||||||
.on("error", function (error) {
|
.on("error", function (error) {
|
||||||
log.error("Error in Watcher", errorTypeCheck(error));
|
log.error("Error in Watcher", errorTypeCheck(error));
|
||||||
|
watcherReady = false;
|
||||||
setWatcherTrayStatus(false);
|
setWatcherTrayStatus(false);
|
||||||
// mainWindow.webContents.send(
|
// mainWindow.webContents.send(
|
||||||
// ipcTypes.toRenderer.watcher.error,
|
// ipcTypes.toRenderer.watcher.error,
|
||||||
@@ -111,6 +138,7 @@ function addWatcherPath(path: string | string[]): void {
|
|||||||
function onWatcherReady(): void {
|
function onWatcherReady(): void {
|
||||||
if (watcher) {
|
if (watcher) {
|
||||||
const mainWindow = getMainWindow();
|
const mainWindow = getMainWindow();
|
||||||
|
watcherReady = true;
|
||||||
new Notification({
|
new Notification({
|
||||||
title: "Watcher Started",
|
title: "Watcher Started",
|
||||||
body: "Newly exported estimates will be automatically uploaded.",
|
body: "Newly exported estimates will be automatically uploaded.",
|
||||||
@@ -126,6 +154,8 @@ async function StopWatcher(): Promise<boolean> {
|
|||||||
|
|
||||||
if (watcher) {
|
if (watcher) {
|
||||||
await watcher.close();
|
await watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
watcherReady = false;
|
||||||
log.info("Watcher stopped.");
|
log.info("Watcher stopped.");
|
||||||
setWatcherTrayStatus(false);
|
setWatcherTrayStatus(false);
|
||||||
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
|
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
|
||||||
@@ -139,6 +169,10 @@ async function StopWatcher(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IsWatcherStarted(): boolean {
|
||||||
|
return watcherReady;
|
||||||
|
}
|
||||||
|
|
||||||
async function HandleNewFile(path): Promise<void> {
|
async function HandleNewFile(path): Promise<void> {
|
||||||
log.log("Received a new file", path);
|
log.log("Received a new file", path);
|
||||||
await ImportJob(path);
|
await ImportJob(path);
|
||||||
@@ -192,6 +226,7 @@ export {
|
|||||||
addWatcherPath,
|
addWatcherPath,
|
||||||
GetAllEnvFiles,
|
GetAllEnvFiles,
|
||||||
GetLatestEnvFile,
|
GetLatestEnvFile,
|
||||||
|
IsWatcherStarted,
|
||||||
removeWatcherPath,
|
removeWatcherPath,
|
||||||
StartWatcher,
|
StartWatcher,
|
||||||
StopWatcher,
|
StopWatcher,
|
||||||
|
|||||||
@@ -28,8 +28,22 @@ const App: FC = () => {
|
|||||||
<HashRouter>
|
<HashRouter>
|
||||||
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<Layout style={{ minHeight: "100vh", background: "#f0f2f5" }}>
|
<Layout
|
||||||
<Layout.Content style={{ padding: "24px" }}>
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layout.Content
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<UpdateAvailable />
|
<UpdateAvailable />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const ErrorBoundaryFallback: FC<FallbackProps> = ({
|
|||||||
<Result
|
<Result
|
||||||
status={"500"}
|
status={"500"}
|
||||||
title={t("errors.errorboundary")}
|
title={t("errors.errorboundary")}
|
||||||
|
// @ts-ignore TS2339
|
||||||
subTitle={error?.message}
|
subTitle={error?.message}
|
||||||
extra={[
|
extra={[
|
||||||
<Button key="try-again" onClick={resetErrorBoundary}>
|
<Button key="try-again" onClick={resetErrorBoundary}>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
DeleteRowOutlined,
|
DeleteRowOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
@@ -13,17 +16,20 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
|
Empty,
|
||||||
Flex,
|
Flex,
|
||||||
|
List,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
theme,
|
theme,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
import { FC, UIEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { selectWatcherStatus } from "@renderer/redux/app.slice";
|
import { selectWatcherStatus } from "@renderer/redux/app.slice";
|
||||||
@@ -32,6 +38,7 @@ import ipcTypes from "../../../../util/ipcTypes.json";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
const SCRUB_HISTORY_PAGE_SIZE = 20;
|
||||||
|
|
||||||
const categoryConfig = {
|
const categoryConfig = {
|
||||||
"Administrative Items": { color: "blue", priority: 1, icon: "📎" },
|
"Administrative Items": { color: "blue", priority: 1, icon: "📎" },
|
||||||
@@ -91,224 +98,283 @@ const Home: FC = () => {
|
|||||||
const [totalJobs, setTotalJobs] = useState<number>(0);
|
const [totalJobs, setTotalJobs] = useState<number>(0);
|
||||||
const [totalResults, setTotalResults] = useState<number>(0);
|
const [totalResults, setTotalResults] = useState<number>(0);
|
||||||
const [lastProcessed, setLastProcessed] = useState<number | null>(null);
|
const [lastProcessed, setLastProcessed] = useState<number | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [loadedPage, setLoadedPage] = useState<number>(1);
|
||||||
const [pageSize, setPageSize] = useState<number>(10);
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||||
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const loadScrubHistoryPage = useCallback(
|
||||||
async (page: number, size: number) => {
|
async (page: number, size: number): Promise<unknown> =>
|
||||||
setLoading(true);
|
ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.getAll, {
|
||||||
try {
|
page,
|
||||||
const response = (await ipcRenderer.invoke(
|
pageSize: size,
|
||||||
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],
|
[ipcRenderer],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyScrubHistoryResponse = useCallback(
|
||||||
|
(response: unknown, page: number, append = false) => {
|
||||||
|
if (isScrubHistoryPage(response)) {
|
||||||
|
setHistory((previous) => {
|
||||||
|
if (!append) return response.items;
|
||||||
|
|
||||||
|
const existingIds = new Set(previous.map((item) => item.id));
|
||||||
|
const nextItems = response.items.filter(
|
||||||
|
(item) => !existingIds.has(item.id),
|
||||||
|
);
|
||||||
|
return [...previous, ...nextItems];
|
||||||
|
});
|
||||||
|
setTotalJobs(response.totalJobs);
|
||||||
|
setTotalResults(response.totalResults);
|
||||||
|
setLastProcessed(response.lastProcessed);
|
||||||
|
setLoadedPage(page);
|
||||||
|
|
||||||
|
if (response.items.length === 0 && response.totalJobs > 0 && page > 1) {
|
||||||
|
setLoadedPage(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,
|
||||||
|
);
|
||||||
|
setLoadedPage(1);
|
||||||
|
} else {
|
||||||
|
setHistory([]);
|
||||||
|
setTotalJobs(0);
|
||||||
|
setTotalResults(0);
|
||||||
|
setLastProcessed(null);
|
||||||
|
setLoadedPage(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(
|
||||||
|
async (page: number, append = false) => {
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await loadScrubHistoryPage(
|
||||||
|
page,
|
||||||
|
SCRUB_HISTORY_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
applyScrubHistoryResponse(response, page, append);
|
||||||
|
} finally {
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(false);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyScrubHistoryResponse, loadScrubHistoryPage],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh(currentPage, pageSize).catch(() => {
|
let cancelled = false;
|
||||||
setLoading(false);
|
|
||||||
});
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await loadScrubHistoryPage(1, SCRUB_HISTORY_PAGE_SIZE);
|
||||||
|
if (!cancelled) {
|
||||||
|
applyScrubHistoryResponse(response, 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
refresh(currentPage, pageSize).catch(() => undefined);
|
refresh(1).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
ipcRenderer.on(ipcTypes.toRenderer.scrub.historyUpdated, handler);
|
const removeHistoryUpdatedListener = ipcRenderer.on(
|
||||||
|
ipcTypes.toRenderer.scrub.historyUpdated,
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(
|
cancelled = true;
|
||||||
ipcTypes.toRenderer.scrub.historyUpdated,
|
removeHistoryUpdatedListener();
|
||||||
handler,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}, [ipcRenderer, refresh, currentPage, pageSize]);
|
}, [applyScrubHistoryResponse, ipcRenderer, loadScrubHistoryPage, refresh]);
|
||||||
|
|
||||||
const totalItemsScrubbed = totalResults;
|
const totalItemsScrubbed = totalResults;
|
||||||
|
const hasMoreHistory = history.length < totalJobs;
|
||||||
|
|
||||||
|
const loadMoreHistory = useCallback(async () => {
|
||||||
|
if (loading || loadingMore || !hasMoreHistory) return;
|
||||||
|
await refresh(loadedPage + 1, true);
|
||||||
|
}, [hasMoreHistory, loadedPage, loading, loadingMore, refresh]);
|
||||||
|
|
||||||
|
const handleHistoryScroll = useCallback(
|
||||||
|
(event: UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
const distanceFromBottom =
|
||||||
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
|
||||||
|
if (distanceFromBottom < 96) {
|
||||||
|
loadMoreHistory().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadMoreHistory],
|
||||||
|
);
|
||||||
|
|
||||||
const deleteJob = useCallback(
|
const deleteJob = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.deleteJob, jobId);
|
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.deleteJob, jobId);
|
||||||
await refresh(currentPage, pageSize);
|
if (selectedJobId === jobId) {
|
||||||
|
setSelectedJobId(null);
|
||||||
|
}
|
||||||
|
await refresh(1);
|
||||||
},
|
},
|
||||||
[ipcRenderer, refresh, currentPage, pageSize],
|
[ipcRenderer, refresh, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearAll = useCallback(async () => {
|
const clearAll = useCallback(async () => {
|
||||||
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.clearAll);
|
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.clearAll);
|
||||||
setCurrentPage(1);
|
setLoadedPage(1);
|
||||||
await refresh(1, pageSize);
|
setSelectedJobId(null);
|
||||||
}, [ipcRenderer, refresh, pageSize]);
|
await refresh(1);
|
||||||
|
}, [ipcRenderer, refresh]);
|
||||||
|
|
||||||
const jobColumns = useMemo(
|
const openScrubReport = useCallback((anchor: string | null) => {
|
||||||
() => [
|
if (!anchor) return;
|
||||||
{
|
void window.api.openExternal(anchor);
|
||||||
title: "Claim #",
|
}, []);
|
||||||
dataIndex: "claimNumber",
|
|
||||||
key: "claimNumber",
|
const openPdf = useCallback(
|
||||||
render: (text: string) => <Text strong>{text}</Text>,
|
(pdfUrl: string | null) => {
|
||||||
},
|
if (!pdfUrl) return;
|
||||||
{
|
ipcRenderer.send(ipcTypes.toMain.openExternal, pdfUrl);
|
||||||
title: "Owner",
|
},
|
||||||
dataIndex: "ownrName",
|
[ipcRenderer],
|
||||||
key: "ownrName",
|
);
|
||||||
},
|
|
||||||
{
|
const renderScrubReportText = useCallback(
|
||||||
title: "Vehicle",
|
(text: string | null, record: ScrubHistoryResultItem, strong = false) => {
|
||||||
dataIndex: "vehicle",
|
const displayText = text?.trim() || "-";
|
||||||
key: "vehicle",
|
|
||||||
responsive: ["md" as const],
|
if (!record.anchor) {
|
||||||
},
|
return (
|
||||||
{
|
<Text
|
||||||
title: "Scrubbed",
|
strong={strong}
|
||||||
dataIndex: "createdAt",
|
type={displayText === "-" ? "secondary" : undefined}
|
||||||
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;
|
|
||||||
ipcRenderer.send(ipcTypes.toMain.openExternal, record.pdfUrl);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Open
|
{displayText}
|
||||||
</Button>
|
</Text>
|
||||||
),
|
);
|
||||||
},
|
}
|
||||||
{
|
|
||||||
title: "Items",
|
return (
|
||||||
key: "items",
|
<Button
|
||||||
width: 90,
|
type="link"
|
||||||
align: "right" as const,
|
icon={<LinkOutlined />}
|
||||||
render: (_: unknown, record: ScrubHistoryItem) => (
|
onClick={() => openScrubReport(record.anchor)}
|
||||||
<Text strong>{record.results?.length ?? 0}</Text>
|
title={record.linktext ?? "Open scrubber report"}
|
||||||
),
|
style={{
|
||||||
},
|
height: "auto",
|
||||||
// {
|
padding: 0,
|
||||||
// title: "Status",
|
whiteSpace: "normal",
|
||||||
// key: "status",
|
textAlign: "left",
|
||||||
// width: 120,
|
lineHeight: 1.4,
|
||||||
// render: () => (
|
}}
|
||||||
// <Tag
|
>
|
||||||
// color="success"
|
<Text
|
||||||
// icon={<CheckCircleOutlined />}
|
strong={strong}
|
||||||
// style={{ margin: 0 }}
|
style={{ color: "inherit", whiteSpace: "normal" }}
|
||||||
// >
|
|
||||||
// Done
|
|
||||||
// </Tag>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: "Actions",
|
|
||||||
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 icon={<DeleteRowOutlined />} danger></Button>
|
{displayText}
|
||||||
</Popconfirm>
|
</Text>
|
||||||
),
|
</Button>
|
||||||
},
|
);
|
||||||
],
|
},
|
||||||
[deleteJob],
|
[openScrubReport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedJob = useMemo(
|
||||||
|
() => history.find((job) => job.id === selectedJobId) ?? null,
|
||||||
|
[history, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resultColumns = useMemo(
|
const resultColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: "Subcategory",
|
title: "Line",
|
||||||
dataIndex: "subcategory",
|
dataIndex: "left",
|
||||||
key: "subcategory",
|
key: "left",
|
||||||
width: "20%",
|
width: "28%",
|
||||||
},
|
|
||||||
{ 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) =>
|
render: (text: string | null, record: ScrubHistoryResultItem) =>
|
||||||
record.linktext ? (
|
renderScrubReportText(text, record, true),
|
||||||
<Button
|
},
|
||||||
onClick={() => {
|
{
|
||||||
if (record.anchor) window.api.openExternal(record.anchor);
|
title: "Extended Line",
|
||||||
}}
|
dataIndex: "right",
|
||||||
disabled={!record.anchor}
|
key: "right",
|
||||||
type="link"
|
width: "72%",
|
||||||
>
|
render: (text: string | null, record: ScrubHistoryResultItem) =>
|
||||||
{text}
|
renderScrubReportText(text, record),
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
width: "10%",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[renderScrubReportText],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedGroups = useMemo(() => {
|
||||||
|
if (!selectedJob) return [];
|
||||||
|
|
||||||
|
const grouped = (selectedJob.results ?? []).reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const key = item.category ?? "Uncategorized";
|
||||||
|
(acc[key] ??= []).push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, ScrubHistoryResultItem[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return 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);
|
||||||
|
});
|
||||||
|
}, [selectedJob]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
|
<div
|
||||||
<Space
|
style={{
|
||||||
orientation="vertical"
|
maxWidth: "1400px",
|
||||||
size="large"
|
height: "100%",
|
||||||
style={{ width: "100%", display: "flex" }}
|
margin: "0 auto",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap="large"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap="small">
|
<Flex justify="space-between" align="center" wrap="wrap" gap="small">
|
||||||
@@ -336,7 +402,7 @@ const Home: FC = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]} style={{ flexShrink: 0 }}>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card
|
<Card
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
@@ -431,113 +497,361 @@ const Home: FC = () => {
|
|||||||
header: {
|
header: {
|
||||||
borderBottom: `2px solid ${token.colorBorderSecondary}`,
|
borderBottom: `2px solid ${token.colorBorderSecondary}`,
|
||||||
},
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Table view for larger screens */}
|
<Flex
|
||||||
<div style={{ display: "block" }}>
|
gap="large"
|
||||||
<Table
|
align="stretch"
|
||||||
columns={jobColumns}
|
style={{ height: "100%", minHeight: 0, overflow: "hidden" }}
|
||||||
dataSource={history}
|
>
|
||||||
rowKey="id"
|
<Flex
|
||||||
loading={loading}
|
vertical
|
||||||
pagination={{
|
gap="middle"
|
||||||
current: currentPage,
|
style={{
|
||||||
pageSize,
|
width: 380,
|
||||||
total: totalJobs,
|
minWidth: 300,
|
||||||
showSizeChanger: true,
|
flexShrink: 0,
|
||||||
pageSizeOptions: [10, 20, 50, 100],
|
height: "100%",
|
||||||
onChange: (nextPage: number, nextSize: number) => {
|
minHeight: 0,
|
||||||
setCurrentPage(nextPage);
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
setPageSize(nextSize);
|
paddingRight: 16,
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 800 }}
|
>
|
||||||
style={{ overflow: "auto" }}
|
<div
|
||||||
expandable={{
|
onScroll={handleHistoryScroll}
|
||||||
expandedRowRender: (record: ScrubHistoryItem) => {
|
style={{
|
||||||
const grouped = (record.results ?? []).reduce(
|
flex: 1,
|
||||||
(acc, item) => {
|
minHeight: 0,
|
||||||
const key = item.category ?? "Uncategorized";
|
overflowY: "scroll",
|
||||||
(acc[key] ??= []).push(item);
|
paddingRight: 4,
|
||||||
return acc;
|
scrollbarGutter: "stable",
|
||||||
},
|
}}
|
||||||
{} as Record<string, ScrubHistoryResultItem[]>,
|
>
|
||||||
);
|
<List<ScrubHistoryItem>
|
||||||
|
dataSource={history}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
split={false}
|
||||||
|
locale={{
|
||||||
|
emptyText: <Empty description="No estimates scrubbed." />,
|
||||||
|
}}
|
||||||
|
renderItem={(record) => {
|
||||||
|
const isSelected = record.id === selectedJobId;
|
||||||
|
|
||||||
const groups = Object.entries(grouped)
|
return (
|
||||||
.map(([category, items]) => ({ category, items }))
|
<List.Item style={{ padding: "0 0 8px" }}>
|
||||||
.sort((a, b) => {
|
<div
|
||||||
const aCfg =
|
role="button"
|
||||||
categoryConfig[
|
tabIndex={0}
|
||||||
a.category as keyof typeof categoryConfig
|
onClick={() => setSelectedJobId(record.id)}
|
||||||
];
|
onKeyDown={(event) => {
|
||||||
const bCfg =
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
categoryConfig[
|
event.preventDefault();
|
||||||
b.category as keyof typeof categoryConfig
|
setSelectedJobId(record.id);
|
||||||
];
|
}
|
||||||
|
}}
|
||||||
const aPriority =
|
style={{
|
||||||
aCfg?.priority ?? Number.POSITIVE_INFINITY;
|
width: "100%",
|
||||||
const bPriority =
|
minHeight: 112,
|
||||||
bCfg?.priority ?? Number.POSITIVE_INFINITY;
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
border: `1px solid ${
|
||||||
return a.category.localeCompare(b.category);
|
isSelected
|
||||||
});
|
? token.colorPrimary
|
||||||
|
: token.colorBorderSecondary
|
||||||
return (
|
}`,
|
||||||
<Space
|
borderRadius: token.borderRadius,
|
||||||
orientation="vertical"
|
background: isSelected
|
||||||
size="middle"
|
? token.colorPrimaryBg
|
||||||
style={{ width: "100%", display: "flex" }}
|
: token.colorBgContainer,
|
||||||
>
|
boxShadow: isSelected
|
||||||
{groups.map(({ category, items }) => {
|
? `0 0 0 2px ${token.colorPrimaryBorder}`
|
||||||
const cfg =
|
: "none",
|
||||||
categoryConfig[
|
padding: 12,
|
||||||
category as keyof typeof categoryConfig
|
transition: "all 0.2s",
|
||||||
];
|
}}
|
||||||
|
>
|
||||||
return (
|
<Flex
|
||||||
<div key={category}>
|
align="stretch"
|
||||||
|
gap="small"
|
||||||
|
style={{ minHeight: 88 }}
|
||||||
|
>
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
vertical
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
style={{ marginBottom: 8 }}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
<Space size="small">
|
<Space
|
||||||
<Text strong>
|
orientation="vertical"
|
||||||
{cfg?.icon ? `${cfg.icon} ` : ""}
|
size={2}
|
||||||
{category}
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Text strong ellipsis style={{ width: "100%" }}>
|
||||||
|
{record.claimNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color={cfg?.color ?? "default"}>
|
<Text ellipsis style={{ width: "100%" }}>
|
||||||
{items.length}
|
{record.ownrName}
|
||||||
</Tag>
|
</Text>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
ellipsis
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
{record.vehicle}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={8}>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
record.pdfUrl
|
||||||
|
? "Open PDF"
|
||||||
|
: "No PDF attached"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="Open PDF"
|
||||||
|
disabled={!record.pdfUrl}
|
||||||
|
icon={<FilePdfOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
height: 22,
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minWidth: 20,
|
||||||
|
padding: 0,
|
||||||
|
width: 20,
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openPdf(record.pdfUrl);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete estimate">
|
||||||
|
<span
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this job?"
|
||||||
|
description="This will also delete its scrub results."
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
onConfirm={(event) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
return deleteJob(record.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="Delete estimate"
|
||||||
|
danger
|
||||||
|
icon={<DeleteRowOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
height: 22,
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minWidth: 20,
|
||||||
|
padding: 0,
|
||||||
|
width: 20,
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Table
|
<Flex
|
||||||
columns={resultColumns}
|
vertical
|
||||||
dataSource={items}
|
justify="space-between"
|
||||||
rowKey={(row) =>
|
align="end"
|
||||||
`${category}-${row.createdAt}-${row.id}`
|
style={{ width: 116, flexShrink: 0 }}
|
||||||
}
|
>
|
||||||
pagination={false}
|
<Text
|
||||||
size="small"
|
type="secondary"
|
||||||
scroll={{ x: 1200 }}
|
style={{ fontSize: 12, whiteSpace: "nowrap" }}
|
||||||
/>
|
>
|
||||||
</div>
|
{dayjs(record.createdAt).format(
|
||||||
);
|
"MMM D / h:mm A",
|
||||||
})}
|
)}
|
||||||
</Space>
|
</Text>
|
||||||
);
|
|
||||||
},
|
<Badge
|
||||||
rowExpandable: (record: ScrubHistoryItem) =>
|
count={record.results?.length ?? 0}
|
||||||
(record.results?.length ?? 0) > 0,
|
color={token.colorPrimary}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{history.length > 0 && (
|
||||||
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
style={{ minHeight: 40, paddingBottom: 8 }}
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : hasMoreHistory ? (
|
||||||
|
<Button type="link" onClick={() => loadMoreHistory()}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{history.length} of {totalJobs} estimates loaded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedJobId(null);
|
||||||
|
refresh(1).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "auto",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
{!selectedJob ? (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{ minHeight: 520 }}
|
||||||
|
>
|
||||||
|
<Empty description="No estimate selected." />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Space
|
||||||
|
orientation="vertical"
|
||||||
|
size="large"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="start" gap="middle" wrap>
|
||||||
|
<Space orientation="vertical" size={2}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
{selectedJob.claimNumber}
|
||||||
|
</Title>
|
||||||
|
<Text>{selectedJob.ownrName}</Text>
|
||||||
|
<Text type="secondary">{selectedJob.vehicle}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
Scrubbed{" "}
|
||||||
|
{dayjs(selectedJob.createdAt).format(
|
||||||
|
"MMM D, YYYY @ h:mm A",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
disabled={!selectedJob.pdfUrl}
|
||||||
|
onClick={() => openPdf(selectedJob.pdfUrl)}
|
||||||
|
>
|
||||||
|
Open PDF
|
||||||
|
</Button>
|
||||||
|
<Tooltip title="Delete estimate">
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this job?"
|
||||||
|
description="This will also delete its scrub results."
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
onConfirm={() => deleteJob(selectedJob.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="Delete estimate"
|
||||||
|
icon={<DeleteRowOutlined />}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{selectedGroups.length === 0 ? (
|
||||||
|
<Empty description="No scrubber results." />
|
||||||
|
) : (
|
||||||
|
selectedGroups.map(({ category, items }) => {
|
||||||
|
const cfg =
|
||||||
|
categoryConfig[category as keyof typeof categoryConfig];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={category}>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap="small"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Text strong>
|
||||||
|
{cfg?.icon ? `${cfg.icon} ` : ""}
|
||||||
|
{category}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
count={items.length}
|
||||||
|
style={{ backgroundColor: token.colorPrimary }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={resultColumns}
|
||||||
|
dataSource={items}
|
||||||
|
rowKey={(row) =>
|
||||||
|
`${category}-${row.createdAt}-${row.id}`
|
||||||
|
}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DeleteOutlined, FolderAddOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, FolderAddOutlined } from "@ant-design/icons";
|
||||||
import { Button, Card, Empty, List, Space, Typography } from "antd";
|
import { Button, Empty, List, Typography } from "antd";
|
||||||
import { useEffect, useState, FC } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ipcTypes from "../../../../util/ipcTypes.json";
|
import ipcTypes from "../../../../util/ipcTypes.json";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { useAppSelector } from "@renderer/redux/reduxHooks";
|
|||||||
import { Affix, Button, Card, Progress, Space, Statistic } from "antd";
|
import { Affix, Button, Card, Progress, Space, Statistic } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ipcTypes from "../../../../util/ipcTypes.json";
|
import ipcTypes from "../../../../util/ipcTypes.json";
|
||||||
import { useState, FC } from "react";
|
import { FC, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const AUTO_APPLY_DELAY_MS = 10 * 1000;
|
||||||
|
|
||||||
const UpdateAvailable: FC = () => {
|
const UpdateAvailable: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -23,10 +25,11 @@ const UpdateAvailable: FC = () => {
|
|||||||
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download);
|
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApply = (): void => {
|
const handleApply = useCallback((): void => {
|
||||||
|
if (applyingUpdate) return;
|
||||||
setApplyingUpdate(true);
|
setApplyingUpdate(true);
|
||||||
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply);
|
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply);
|
||||||
};
|
}, [applyingUpdate]);
|
||||||
|
|
||||||
if (!isUpdateAvailable) {
|
if (!isUpdateAvailable) {
|
||||||
return null;
|
return null;
|
||||||
@@ -62,13 +65,7 @@ const UpdateAvailable: FC = () => {
|
|||||||
>
|
>
|
||||||
{applyingUpdate ? t("updates.applying") : t("updates.apply")}
|
{applyingUpdate ? t("updates.applying") : t("updates.apply")}
|
||||||
</Button>
|
</Button>
|
||||||
<Statistic.Countdown
|
<AutoApplyCountdown onFinish={handleApply} />
|
||||||
title="Auto update in:"
|
|
||||||
format="mm:ss"
|
|
||||||
style={{ width: "100%", textAlign: "center" }}
|
|
||||||
value={Date.now() + 10 * 1000}
|
|
||||||
onFinish={(): void => handleApply()}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -79,6 +76,51 @@ const UpdateAvailable: FC = () => {
|
|||||||
|
|
||||||
export default UpdateAvailable;
|
export default UpdateAvailable;
|
||||||
|
|
||||||
|
const AutoApplyCountdown: FC<{ onFinish: () => void }> = ({ onFinish }) => {
|
||||||
|
const [remainingMs, setRemainingMs] = useState(AUTO_APPLY_DELAY_MS);
|
||||||
|
const onFinishRef = useRef(onFinish);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFinishRef.current = onFinish;
|
||||||
|
}, [onFinish]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
const nextRemainingMs = Math.max(
|
||||||
|
AUTO_APPLY_DELAY_MS - (Date.now() - startedAt),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
setRemainingMs(nextRemainingMs);
|
||||||
|
|
||||||
|
if (nextRemainingMs === 0) {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
onFinishRef.current();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
||||||
|
const minutes = Math.floor(remainingSeconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const seconds = (remainingSeconds % 60).toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Statistic
|
||||||
|
title="Auto update in:"
|
||||||
|
style={{ width: "100%", textAlign: "center" }}
|
||||||
|
value={`${minutes}:${seconds}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats bytes into a human-readable string with appropriate units
|
* Formats bytes into a human-readable string with appropriate units
|
||||||
* @param bytes Number of bytes
|
* @param bytes Number of bytes
|
||||||
|
|||||||
11
src/renderer/src/index.css
Normal file
11
src/renderer/src/index.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
import "./util/i18n";
|
import "./util/i18n";
|
||||||
import "./util/ipcRendererHandler";
|
import "./util/ipcRendererHandler";
|
||||||
import * as Sentry from "@sentry/electron/renderer";
|
import * as Sentry from "@sentry/electron/renderer";
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ ipcRenderer.on(ipcTypes.toRenderer.watcher.started, () => {
|
|||||||
dispatch(watcherStarted());
|
dispatch(watcherStarted());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcRenderer
|
||||||
|
.invoke(ipcTypes.toMain.watcher.status)
|
||||||
|
.then((started: boolean) => {
|
||||||
|
dispatch(started ? watcherStarted() : watcherStopped());
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to get watcher status", error);
|
||||||
|
});
|
||||||
|
|
||||||
ipcRenderer.on(ipcTypes.toRenderer.watcher.stopped, () => {
|
ipcRenderer.on(ipcTypes.toRenderer.watcher.stopped, () => {
|
||||||
console.log("Watcher has stopped");
|
console.log("Watcher has stopped");
|
||||||
dispatch(watcherStopped());
|
dispatch(watcherStopped());
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
},
|
},
|
||||||
"watcher": {
|
"watcher": {
|
||||||
"start": "toMain_watcher_start",
|
"start": "toMain_watcher_start",
|
||||||
"stop": "toMain_watcher_stop"
|
"stop": "toMain_watcher_stop",
|
||||||
|
"status": "toMain_watcher_status"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"filepaths": {
|
"filepaths": {
|
||||||
|
|||||||
@@ -63,6 +63,9 @@
|
|||||||
"watchermodepolling": "Polling",
|
"watchermodepolling": "Polling",
|
||||||
"watchermoderealtime": "Real Time",
|
"watchermoderealtime": "Real Time",
|
||||||
"watcherstatus": "Watcher Status"
|
"watcherstatus": "Watcher Status"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"esApiKeyRequired": "Estimate Scrubber API Key is required."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
|
|||||||
Reference in New Issue
Block a user