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

This commit is contained in:
Dave
2026-05-26 11:03:22 -04:00
parent 40812f2092
commit 122c0cebeb
16 changed files with 758 additions and 308 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
}
* {
box-sizing: border-box;
}

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {