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 { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
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 axios, { AxiosError } from 'axios';
import axios from 'axios';
import FormData from 'form-data';
import { GraphQLClient, gql } from 'graphql-request';
import { gql, GraphQLClient } from 'graphql-request';
import { ESJobObject, RawJobDataObject } from '../../../shared/types';
import { transformJobForEstimateScrubber } from '../lib/transformEstimate';
import { getVehicleType } from '../lib/vehicleTypes/vehicleType';

View File

@@ -77,7 +77,7 @@ const DecodeStl = async (
return singleLineData;
});
//Apply business logic transfomrations.
//Apply business logic transformations.
//We don't have an inspection date, we instead have `date_estimated`
return { cieca_stl: { data: rawStlData } };

View File

@@ -591,7 +591,20 @@ app.whenReady().then(async () => {
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.
openMainWindow();

View File

@@ -2,7 +2,11 @@ import { ipcMain } from "electron";
import log from "electron-log/main";
import { autoUpdater } from "electron-updater";
import ipcTypes from "../../util/ipcTypes.json";
import { StartWatcher, StopWatcher } from "../watcher/watcher";
import {
IsWatcherStarted,
StartWatcher,
StopWatcher,
} from "../watcher/watcher";
import {
ScrubHistoryClearAll,
ScrubHistoryDeleteJob,
@@ -89,6 +93,8 @@ ipcMain.on(ipcTypes.toMain.watcher.stop, () => {
});
});
ipcMain.handle(ipcTypes.toMain.watcher.status, () => IsWatcherStarted());
ipcMain.on(ipcTypes.toMain.updates.download, () => {
log.info("Download update requested from renderer.");
autoUpdater.downloadUpdate().catch((error) => {

View File

@@ -10,17 +10,41 @@ import store from "../store/store";
import getMainWindow from "../../util/getMainWindow";
import { setWatcherTrayStatus } from "../util/trayStatus";
let watcher: FSWatcher | null;
let watcherReady = false;
async function StartWatcher(): Promise<boolean> {
const filePaths: string[] = store.get("settings.filepaths") || [];
type StartWatcherOptions = {
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) {
new Notification({
//TODO: Add Translations
title: "Watcher cannot start",
body: "Please set the appropriate file paths and try again.",
}).show();
log.warn("Cannot start watcher. No file paths set.");
if (notifyOnNoPaths) {
new Notification({
//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,
});
return false;
}
@@ -28,6 +52,8 @@ async function StartWatcher(): Promise<boolean> {
try {
log.info("Trying to close watcher - it already existed.");
await watcher.close();
watcher = null;
watcherReady = false;
log.info("Watcher closed successfully!");
} catch (error) {
@@ -79,6 +105,7 @@ async function StartWatcher(): Promise<boolean> {
// })
.on("error", function (error) {
log.error("Error in Watcher", errorTypeCheck(error));
watcherReady = false;
setWatcherTrayStatus(false);
// mainWindow.webContents.send(
// ipcTypes.toRenderer.watcher.error,
@@ -111,6 +138,7 @@ function addWatcherPath(path: string | string[]): void {
function onWatcherReady(): void {
if (watcher) {
const mainWindow = getMainWindow();
watcherReady = true;
new Notification({
title: "Watcher Started",
body: "Newly exported estimates will be automatically uploaded.",
@@ -126,6 +154,8 @@ async function StopWatcher(): Promise<boolean> {
if (watcher) {
await watcher.close();
watcher = null;
watcherReady = false;
log.info("Watcher stopped.");
setWatcherTrayStatus(false);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
@@ -139,6 +169,10 @@ async function StopWatcher(): Promise<boolean> {
return false;
}
function IsWatcherStarted(): boolean {
return watcherReady;
}
async function HandleNewFile(path): Promise<void> {
log.log("Received a new file", path);
await ImportJob(path);
@@ -192,6 +226,7 @@ export {
addWatcherPath,
GetAllEnvFiles,
GetLatestEnvFile,
IsWatcherStarted,
removeWatcherPath,
StartWatcher,
StopWatcher,

View File

@@ -28,8 +28,22 @@ const App: FC = () => {
<HashRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<NotificationProvider>
<Layout style={{ minHeight: "100vh", background: "#f0f2f5" }}>
<Layout.Content style={{ padding: "24px" }}>
<Layout
style={{
height: "100vh",
minHeight: 0,
overflow: "hidden",
background: "#f0f2f5",
}}
>
<Layout.Content
style={{
height: "100%",
minHeight: 0,
overflow: "auto",
padding: "24px",
}}
>
<UpdateAvailable />
<Routes>
<Route path="/" element={<Home />} />

View File

@@ -12,6 +12,7 @@ const ErrorBoundaryFallback: FC<FallbackProps> = ({
<Result
status={"500"}
title={t("errors.errorboundary")}
// @ts-ignore TS2339
subTitle={error?.message}
extra={[
<Button key="try-again" onClick={resetErrorBoundary}>

View File

@@ -5,7 +5,10 @@ import {
DatabaseOutlined,
DeleteOutlined,
DeleteRowOutlined,
FilePdfOutlined,
FileTextOutlined,
LinkOutlined,
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import {
@@ -13,17 +16,20 @@ import {
Badge,
Card,
Col,
Empty,
Flex,
List,
Popconfirm,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Tooltip,
Typography,
theme,
} 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 { useNavigate } from "react-router";
import { selectWatcherStatus } from "@renderer/redux/app.slice";
@@ -32,6 +38,7 @@ import ipcTypes from "../../../../util/ipcTypes.json";
import dayjs from "dayjs";
const { Title, Text } = Typography;
const SCRUB_HISTORY_PAGE_SIZE = 20;
const categoryConfig = {
"Administrative Items": { color: "blue", priority: 1, icon: "📎" },
@@ -91,224 +98,283 @@ const Home: FC = () => {
const [totalJobs, setTotalJobs] = useState<number>(0);
const [totalResults, setTotalResults] = useState<number>(0);
const [lastProcessed, setLastProcessed] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [loadedPage, setLoadedPage] = useState<number>(1);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const refresh = useCallback(
async (page: number, size: number) => {
setLoading(true);
try {
const response = (await ipcRenderer.invoke(
ipcTypes.toMain.scrubHistory.getAll,
{ page, pageSize: size },
)) as unknown;
if (isScrubHistoryPage(response)) {
setHistory(response.items);
setTotalJobs(response.totalJobs);
setTotalResults(response.totalResults);
setLastProcessed(response.lastProcessed);
if (
response.items.length === 0 &&
response.totalJobs > 0 &&
page > 1
) {
setCurrentPage(page - 1);
}
} else if (Array.isArray(response)) {
setHistory(response as ScrubHistoryItem[]);
setTotalJobs((response as ScrubHistoryItem[]).length);
setTotalResults(
(response as ScrubHistoryItem[]).reduce(
(acc, job) => acc + (job.results?.length ?? 0),
0,
),
);
setLastProcessed(
(response as ScrubHistoryItem[])[0]?.createdAt ?? null,
);
} else {
setHistory([]);
setTotalJobs(0);
setTotalResults(0);
setLastProcessed(null);
}
} finally {
setLoading(false);
}
},
const loadScrubHistoryPage = useCallback(
async (page: number, size: number): Promise<unknown> =>
ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.getAll, {
page,
pageSize: size,
}),
[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(() => {
refresh(currentPage, pageSize).catch(() => {
setLoading(false);
});
let cancelled = 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 = () => {
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 () => {
ipcRenderer.removeListener(
ipcTypes.toRenderer.scrub.historyUpdated,
handler,
);
cancelled = true;
removeHistoryUpdatedListener();
};
}, [ipcRenderer, refresh, currentPage, pageSize]);
}, [applyScrubHistoryResponse, ipcRenderer, loadScrubHistoryPage, refresh]);
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(
async (jobId: string) => {
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 () => {
await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.clearAll);
setCurrentPage(1);
await refresh(1, pageSize);
}, [ipcRenderer, refresh, pageSize]);
setLoadedPage(1);
setSelectedJobId(null);
await refresh(1);
}, [ipcRenderer, refresh]);
const jobColumns = useMemo(
() => [
{
title: "Claim #",
dataIndex: "claimNumber",
key: "claimNumber",
render: (text: string) => <Text strong>{text}</Text>,
},
{
title: "Owner",
dataIndex: "ownrName",
key: "ownrName",
},
{
title: "Vehicle",
dataIndex: "vehicle",
key: "vehicle",
responsive: ["md" as const],
},
{
title: "Scrubbed",
dataIndex: "createdAt",
key: "createdAt",
responsive: ["sm" as const],
render: (value: number) => {
const date = new Date(value);
return (
<Space orientation="vertical" size={0}>
<Text>{date.toLocaleDateString()}</Text>
<Text type="secondary" style={{ fontSize: "12px" }}>
{date.toLocaleTimeString()}
</Text>
</Space>
);
},
},
{
title: "PDF",
key: "pdf",
width: 90,
render: (_: unknown, record: ScrubHistoryItem) => (
<Button
type="link"
disabled={!record.pdfUrl}
onClick={() => {
if (!record.pdfUrl) return;
ipcRenderer.send(ipcTypes.toMain.openExternal, record.pdfUrl);
}}
const openScrubReport = useCallback((anchor: string | null) => {
if (!anchor) return;
void window.api.openExternal(anchor);
}, []);
const openPdf = useCallback(
(pdfUrl: string | null) => {
if (!pdfUrl) return;
ipcRenderer.send(ipcTypes.toMain.openExternal, pdfUrl);
},
[ipcRenderer],
);
const renderScrubReportText = useCallback(
(text: string | null, record: ScrubHistoryResultItem, strong = false) => {
const displayText = text?.trim() || "-";
if (!record.anchor) {
return (
<Text
strong={strong}
type={displayText === "-" ? "secondary" : undefined}
>
Open
</Button>
),
},
{
title: "Items",
key: "items",
width: 90,
align: "right" as const,
render: (_: unknown, record: ScrubHistoryItem) => (
<Text strong>{record.results?.length ?? 0}</Text>
),
},
// {
// title: "Status",
// key: "status",
// width: 120,
// render: () => (
// <Tag
// color="success"
// icon={<CheckCircleOutlined />}
// style={{ margin: 0 }}
// >
// Done
// </Tag>
// ),
// },
{
title: "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)}
{displayText}
</Text>
);
}
return (
<Button
type="link"
icon={<LinkOutlined />}
onClick={() => openScrubReport(record.anchor)}
title={record.linktext ?? "Open scrubber report"}
style={{
height: "auto",
padding: 0,
whiteSpace: "normal",
textAlign: "left",
lineHeight: 1.4,
}}
>
<Text
strong={strong}
style={{ color: "inherit", whiteSpace: "normal" }}
>
<Button icon={<DeleteRowOutlined />} danger></Button>
</Popconfirm>
),
},
],
[deleteJob],
{displayText}
</Text>
</Button>
);
},
[openScrubReport],
);
const selectedJob = useMemo(
() => history.find((job) => job.id === selectedJobId) ?? null,
[history, selectedJobId],
);
const resultColumns = useMemo(
() => [
{
title: "Subcategory",
dataIndex: "subcategory",
key: "subcategory",
width: "20%",
},
{ title: "Item", dataIndex: "left", key: "left", width: "20%" },
{ title: "Description", dataIndex: "right", key: "right", width: "50%" },
{
title: "Link",
dataIndex: "linktext",
key: "linktext",
title: "Line",
dataIndex: "left",
key: "left",
width: "28%",
render: (text: string | null, record: ScrubHistoryResultItem) =>
record.linktext ? (
<Button
onClick={() => {
if (record.anchor) window.api.openExternal(record.anchor);
}}
disabled={!record.anchor}
type="link"
>
{text}
</Button>
) : null,
width: "10%",
renderScrubReportText(text, record, true),
},
{
title: "Extended Line",
dataIndex: "right",
key: "right",
width: "72%",
render: (text: string | null, record: ScrubHistoryResultItem) =>
renderScrubReportText(text, record),
},
],
[],
[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 (
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
<Space
orientation="vertical"
size="large"
style={{ width: "100%", display: "flex" }}
<div
style={{
maxWidth: "1400px",
height: "100%",
margin: "0 auto",
overflow: "hidden",
}}
>
<Flex
vertical
gap="large"
style={{
width: "100%",
height: "100%",
minHeight: 0,
}}
>
{/* Header */}
<Flex justify="space-between" align="center" wrap="wrap" gap="small">
@@ -336,7 +402,7 @@ const Home: FC = () => {
</Flex>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
<Row gutter={[16, 16]} style={{ flexShrink: 0 }}>
<Col xs={24} sm={12} lg={8}>
<Card
variant="borderless"
@@ -431,113 +497,361 @@ const Home: FC = () => {
header: {
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 */}
<div style={{ display: "block" }}>
<Table
columns={jobColumns}
dataSource={history}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize,
total: totalJobs,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
onChange: (nextPage: number, nextSize: number) => {
setCurrentPage(nextPage);
setPageSize(nextSize);
},
<Flex
gap="large"
align="stretch"
style={{ height: "100%", minHeight: 0, overflow: "hidden" }}
>
<Flex
vertical
gap="middle"
style={{
width: 380,
minWidth: 300,
flexShrink: 0,
height: "100%",
minHeight: 0,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingRight: 16,
}}
scroll={{ x: 800 }}
style={{ overflow: "auto" }}
expandable={{
expandedRowRender: (record: ScrubHistoryItem) => {
const grouped = (record.results ?? []).reduce(
(acc, item) => {
const key = item.category ?? "Uncategorized";
(acc[key] ??= []).push(item);
return acc;
},
{} as Record<string, ScrubHistoryResultItem[]>,
);
>
<div
onScroll={handleHistoryScroll}
style={{
flex: 1,
minHeight: 0,
overflowY: "scroll",
paddingRight: 4,
scrollbarGutter: "stable",
}}
>
<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)
.map(([category, items]) => ({ category, items }))
.sort((a, b) => {
const aCfg =
categoryConfig[
a.category as keyof typeof categoryConfig
];
const bCfg =
categoryConfig[
b.category as keyof typeof categoryConfig
];
const aPriority =
aCfg?.priority ?? Number.POSITIVE_INFINITY;
const bPriority =
bCfg?.priority ?? Number.POSITIVE_INFINITY;
if (aPriority !== bPriority) return aPriority - bPriority;
return a.category.localeCompare(b.category);
});
return (
<Space
orientation="vertical"
size="middle"
style={{ width: "100%", display: "flex" }}
>
{groups.map(({ category, items }) => {
const cfg =
categoryConfig[
category as keyof typeof categoryConfig
];
return (
<div key={category}>
return (
<List.Item style={{ padding: "0 0 8px" }}>
<div
role="button"
tabIndex={0}
onClick={() => setSelectedJobId(record.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedJobId(record.id);
}
}}
style={{
width: "100%",
minHeight: 112,
cursor: "pointer",
textAlign: "left",
border: `1px solid ${
isSelected
? token.colorPrimary
: token.colorBorderSecondary
}`,
borderRadius: token.borderRadius,
background: isSelected
? token.colorPrimaryBg
: token.colorBgContainer,
boxShadow: isSelected
? `0 0 0 2px ${token.colorPrimaryBorder}`
: "none",
padding: 12,
transition: "all 0.2s",
}}
>
<Flex
align="stretch"
gap="small"
style={{ minHeight: 88 }}
>
<Flex
align="center"
vertical
justify="space-between"
style={{ marginBottom: 8 }}
style={{ flex: 1, minWidth: 0 }}
>
<Space size="small">
<Text strong>
{cfg?.icon ? `${cfg.icon} ` : ""}
{category}
<Space
orientation="vertical"
size={2}
style={{ width: "100%" }}
>
<Text strong ellipsis style={{ width: "100%" }}>
{record.claimNumber}
</Text>
<Tag color={cfg?.color ?? "default"}>
{items.length}
</Tag>
<Text ellipsis style={{ width: "100%" }}>
{record.ownrName}
</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>
</Flex>
<Table
columns={resultColumns}
dataSource={items}
rowKey={(row) =>
`${category}-${row.createdAt}-${row.id}`
}
pagination={false}
size="small"
scroll={{ x: 1200 }}
/>
</div>
);
})}
</Space>
);
},
rowExpandable: (record: ScrubHistoryItem) =>
(record.results?.length ?? 0) > 0,
<Flex
vertical
justify="space-between"
align="end"
style={{ width: 116, flexShrink: 0 }}
>
<Text
type="secondary"
style={{ fontSize: 12, whiteSpace: "nowrap" }}
>
{dayjs(record.createdAt).format(
"MMM D / h:mm A",
)}
</Text>
<Badge
count={record.results?.length ?? 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>
</Space>
</Flex>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { DeleteOutlined, FolderAddOutlined } from "@ant-design/icons";
import { Button, Card, Empty, List, Space, Typography } from "antd";
import { useEffect, useState, FC } from "react";
import { Button, Empty, List, Typography } from "antd";
import { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
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 { useTranslation } from "react-i18next";
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 { t } = useTranslation();
@@ -23,10 +25,11 @@ const UpdateAvailable: FC = () => {
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download);
};
const handleApply = (): void => {
const handleApply = useCallback((): void => {
if (applyingUpdate) return;
setApplyingUpdate(true);
window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply);
};
}, [applyingUpdate]);
if (!isUpdateAvailable) {
return null;
@@ -62,13 +65,7 @@ const UpdateAvailable: FC = () => {
>
{applyingUpdate ? t("updates.applying") : t("updates.apply")}
</Button>
<Statistic.Countdown
title="Auto update in:"
format="mm:ss"
style={{ width: "100%", textAlign: "center" }}
value={Date.now() + 10 * 1000}
onFinish={(): void => handleApply()}
/>
<AutoApplyCountdown onFinish={handleApply} />
</>
)}
</Space>
@@ -79,6 +76,51 @@ const UpdateAvailable: FC = () => {
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
* @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 ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import "./util/i18n";
import "./util/ipcRendererHandler";
import * as Sentry from "@sentry/electron/renderer";

View File

@@ -36,6 +36,15 @@ ipcRenderer.on(ipcTypes.toRenderer.watcher.started, () => {
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, () => {
console.log("Watcher has stopped");
dispatch(watcherStopped());

View File

@@ -18,7 +18,8 @@
},
"watcher": {
"start": "toMain_watcher_start",
"stop": "toMain_watcher_stop"
"stop": "toMain_watcher_stop",
"status": "toMain_watcher_status"
},
"settings": {
"filepaths": {

View File

@@ -63,6 +63,9 @@
"watchermodepolling": "Polling",
"watchermoderealtime": "Real Time",
"watcherstatus": "Watcher Status"
},
"validation": {
"esApiKeyRequired": "Estimate Scrubber API Key is required."
}
},
"title": {