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 { 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' });
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
},
|
||||
"watcher": {
|
||||
"start": "toMain_watcher_start",
|
||||
"stop": "toMain_watcher_stop"
|
||||
"stop": "toMain_watcher_stop",
|
||||
"status": "toMain_watcher_status"
|
||||
},
|
||||
"settings": {
|
||||
"filepaths": {
|
||||
|
||||
@@ -63,6 +63,9 @@
|
||||
"watchermodepolling": "Polling",
|
||||
"watchermoderealtime": "Real Time",
|
||||
"watcherstatus": "Watcher Status"
|
||||
},
|
||||
"validation": {
|
||||
"esApiKeyRequired": "Estimate Scrubber API Key is required."
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
|
||||
Reference in New Issue
Block a user