feature/IO-3725-RPS-Changes - Styling / Layout fixes (Dark mode / Settings page) / Add VIN Lookup

This commit is contained in:
Dave
2026-06-01 13:26:16 -04:00
parent 7a3a7513b4
commit 0e2530a54e
12 changed files with 673 additions and 517 deletions

View File

@@ -9,6 +9,7 @@ const storeOptions = {
enableNotifications: true,
filePaths: [],
accepted_ins_co: [],
darkMode: false,
runWatcherOnStartup: true,
polling: {
enabled: false,

View File

@@ -41,11 +41,32 @@ function AntdFeedbackBridge() {
return null;
}
function AppShell({ currentUser }) {
const { token } = theme.useToken();
return (
<div
className="imex-app-shell"
style={{
"--imex-scrollbar-track": token.colorBgContainer,
"--imex-scrollbar-thumb": token.colorFillSecondary,
"--imex-scrollbar-thumb-hover": token.colorFill
}}
>
{currentUser.authorized ? <RoutesPage /> : <SignInPage />}
</div>
);
}
export function App({ currentUser, checkUserSession, darkMode }) {
useEffect(() => {
checkUserSession();
}, [checkUserSession]);
useEffect(() => {
ipcRenderer.send(ipcTypes.store.get, "darkMode");
}, []);
useEffect(() => {
if (currentUser && currentUser.email) {
ipcRenderer.send(ipcTypes.app.toMain.setUserName, currentUser.email);
@@ -75,7 +96,7 @@ export function App({ currentUser, checkUserSession, darkMode }) {
>
<AntdApp>
<AntdFeedbackBridge />
<div>{currentUser.authorized ? <RoutesPage /> : <SignInPage />}</div>
<AppShell currentUser={currentUser} />
</AntdApp>
</ConfigProvider>
</ApolloProvider>

View File

@@ -47,23 +47,35 @@ body {
overflow: hidden;
white-space: nowrap;
}
// ::-webkit-scrollbar-track {
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// border-radius: 0.2rem;
// background-color: #f5f5f5;
// }
// ::-webkit-scrollbar {
// width: 0.25rem;
// max-height: 0.25rem;
// background-color: #f5f5f5;
// }
.imex-app-shell {
scrollbar-color: var(--imex-scrollbar-thumb) var(--imex-scrollbar-track);
scrollbar-width: thin;
}
.imex-app-shell * {
scrollbar-color: var(--imex-scrollbar-thumb) var(--imex-scrollbar-track);
}
.imex-app-shell ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.imex-app-shell ::-webkit-scrollbar-track {
background-color: var(--imex-scrollbar-track);
}
.imex-app-shell ::-webkit-scrollbar-thumb {
background-color: var(--imex-scrollbar-thumb);
border: 3px solid var(--imex-scrollbar-track);
border-radius: 999px;
}
.imex-app-shell ::-webkit-scrollbar-thumb:hover {
background-color: var(--imex-scrollbar-thumb-hover);
}
// ::-webkit-scrollbar-thumb {
// border-radius: 0.2rem;
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// background-color: #188fff;
// }
.jobs-list-container {
height: 100%;
display: flex;

View File

@@ -1,310 +1,343 @@
import { InfoCircleOutlined, LinkOutlined, SearchOutlined } from "@ant-design/icons";
import { Badge, Button, Collapse, Input, Result, Space, Table, Tag, Typography } from "antd";
import {FilePdfOutlined, FlagOutlined, LinkOutlined, SearchOutlined} from "@ant-design/icons";
import {Badge, Button, Collapse, Input, Result, Space, Table, Tag, Typography} from "antd";
import _ from "lodash";
import { useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectEsResults } from "../../../redux/application/application.selectors";
import { selectBodyshop } from "../../../redux/user/user.selectors";
import {useState} from "react";
import {connect} from "react-redux";
import {createStructuredSelector} from "reselect";
import {selectEsResults} from "../../../redux/application/application.selectors";
import {selectBodyshop} from "../../../redux/user/user.selectors";
import EstimateScrubberButton from "../estimate-scrubber-button/estimate-scrubber-button.molecule";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
esResults: selectEsResults
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
esResults: selectEsResults
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(EstimateScrubberResults);
const { Title, Text, Link } = Typography;
const {Title, Text, Link} = Typography;
function buildVinLookupUrl(vin) {
if (!vin) return null;
return `https://www.vtechtoolkit.com/ES_NHTSA.aspx?Vin=${encodeURIComponent(vin)}`;
}
function buildCpsUrl(bodyshop) {
const apiKey = bodyshop?.es_api_key;
if (bodyshop?.ins_rule_set !== "MPI" || !apiKey) return null;
return `https://www.EstimateScrubber.com/ATAM.aspx?apiKey=${encodeURIComponent(apiKey)}`;
}
function getOrderedListParts(text) {
if (typeof text !== "string") return null;
if (typeof text !== "string") return null;
const matches = [];
const markerPattern = /(\d{1,2})\.\s+/g;
let match;
const matches = [];
const markerPattern = /(\d{1,2})\.\s+/g;
let match;
while ((match = markerPattern.exec(text)) !== null) {
const markerStart = match.index;
const previousCharacter = text[markerStart - 1];
while ((match = markerPattern.exec(text)) !== null) {
const markerStart = match.index;
const previousCharacter = text[markerStart - 1];
if (markerStart > 0 && !/\s/.test(previousCharacter)) continue;
if (markerStart > 0 && !/\s/.test(previousCharacter)) continue;
matches.push({
number: Number(match[1]),
markerStart,
contentStart: markerPattern.lastIndex
});
}
if (matches.length < 2) return null;
const listStartIndex = matches.findIndex((item, index) => {
const nextItem = matches[index + 1];
return item.number === 1 && nextItem?.number === 2;
});
if (listStartIndex === -1) return null;
const listMatches = matches.slice(listStartIndex);
const sequentialEndIndex = listMatches.findIndex((item, index) => item.number !== index + 1);
const orderedMatches = sequentialEndIndex === -1 ? listMatches : listMatches.slice(0, sequentialEndIndex);
const orderedListEnd = sequentialEndIndex === -1 ? text.length : listMatches[sequentialEndIndex].markerStart;
if (orderedMatches.length < 2) return null;
const items = orderedMatches
.map((item, index) => {
const nextMarkerStart = orderedMatches[index + 1]?.markerStart ?? orderedListEnd;
return text.slice(item.contentStart, nextMarkerStart).trim();
})
.filter(Boolean);
if (items.length < 2) return null;
return {
prefix: text.slice(0, orderedMatches[0].markerStart).trim(),
items
};
}
function ScrubberDescription({ text }) {
const orderedListParts = getOrderedListParts(text);
if (!orderedListParts) return <Text>{text}</Text>;
return (
<div>
{orderedListParts.prefix && <Text>{orderedListParts.prefix}</Text>}
<ol
style={{
margin: orderedListParts.prefix ? "8px 0 0 20px" : "0 0 0 20px",
paddingLeft: 16
}}
>
{orderedListParts.items.map((item, index) => (
<li key={`${index}-${item}`}>
<Text>{item}</Text>
</li>
))}
</ol>
</div>
);
}
export function EstimateScrubberResults({ bodyshop, jobid, job, esResults }) {
const [searchText, setSearchText] = useState("");
const buttonDisabled = job?.g_bett_amt == null;
// Filter items based on search text
const itemsWithKeys = esResults?.items
? esResults.items.map((item, itemIndex) => ({
...item,
scrubberRowKey: [item.SubCategory, item.L, item.R, item.Anchor, itemIndex].filter(Boolean).join("|")
}))
: [];
const filteredItems = itemsWithKeys.length
? itemsWithKeys.filter((item) => {
if (!searchText.trim()) return true;
const searchLower = searchText.toLowerCase();
return (
(item.L && item.L.toLowerCase().includes(searchLower)) ||
(item.R && item.R.toLowerCase().includes(searchLower))
);
})
: [];
// Group filtered items by category
const groupedItems = filteredItems.length
? _.groupBy(
filteredItems.filter((item) => item.SubCategory !== "In Main Display Group - Table Level"),
"SubCategory"
)
: {};
// Define category colors and priorities
const categoryConfig = {
"Administrative Items": { color: "blue", priority: 1, icon: "📎" },
"Rates Issues": { color: "blue", priority: 2, icon: "💵" },
"MPI Guidelines Items": { color: "blue", priority: 3, icon: "📋" },
"Estimator Recommendations": { color: "blue", priority: 4, icon: "✅" },
"Estimate Parts Found": { color: "blue", priority: 5, icon: "🔧" },
"All Parts Found": { color: "blue", priority: 6, icon: "🔧" }
};
// Sort categories by priority
const sortedCategories = Object.keys(groupedItems).sort((a, b) => {
const priorityA = categoryConfig[a]?.priority || 999;
const priorityB = categoryConfig[b]?.priority || 999;
return priorityA - priorityB;
});
// Define table columns
const columns = [
{
title: "Item",
dataIndex: "L",
key: "item",
width: "25%",
render: (text, record) => (
<Space direction="vertical" size="small">
<Text strong>{text}</Text>
{record.LinkText && record.Anchor && (
<Link href={record.Anchor} target="_blank" rel="noopener noreferrer">
<LinkOutlined /> Learn more
</Link>
)}
</Space>
)
},
{
title: "Description",
dataIndex: "R",
key: "description",
width: "75%",
render: (text) => <ScrubberDescription text={text} />
matches.push({
number: Number(match[1]),
markerStart,
contentStart: markerPattern.lastIndex
});
}
];
const collapseItems = sortedCategories.map((category) => {
const items = groupedItems[category];
const config = categoryConfig[category] || { color: "default", icon: "📄" };
if (matches.length < 2) return null;
const listStartIndex = matches.findIndex((item, index) => {
const nextItem = matches[index + 1];
return item.number === 1 && nextItem?.number === 2;
});
if (listStartIndex === -1) return null;
const listMatches = matches.slice(listStartIndex);
const sequentialEndIndex = listMatches.findIndex((item, index) => item.number !== index + 1);
const orderedMatches = sequentialEndIndex === -1 ? listMatches : listMatches.slice(0, sequentialEndIndex);
const orderedListEnd = sequentialEndIndex === -1 ? text.length : listMatches[sequentialEndIndex].markerStart;
if (orderedMatches.length < 2) return null;
const items = orderedMatches
.map((item, index) => {
const nextMarkerStart = orderedMatches[index + 1]?.markerStart ?? orderedListEnd;
return text.slice(item.contentStart, nextMarkerStart).trim();
})
.filter(Boolean);
if (items.length < 2) return null;
return {
key: category,
label: (
<Space>
{/* <span style={{ fontSize: "18px" }}>{config.icon}</span> */}
<Text strong>{category}</Text>
<Badge
count={items.length}
style={{
backgroundColor: config.color === "blue" ? "#1890ff" : config.color === "orange" ? "#fa8c16" : "#52c41a"
}}
/>
</Space>
),
children: (
<Table
dataSource={items}
columns={columns}
pagination={false}
size="middle"
rowKey="scrubberRowKey"
style={{ marginTop: "12px" }}
scroll={{}}
/>
)
prefix: text.slice(0, orderedMatches[0].markerStart).trim(),
items
};
});
if (!esResults?.items?.length || job?.id !== esResults?.jobid) {
return (
<Result
status={buttonDisabled ? "error" : "info"}
title={buttonDisabled ? "Unable to scrub." : "Estimate Not Scrubbed"}
subTitle={
buttonDisabled
? "Additional information is required to scrub this estimate. Please reimport the estimate to enable scrubbing."
: "Run the estimate scrubber to see results here."
}
extra={<EstimateScrubberButton key="es" jobid={job ? job.id : null} job={job ? job : null} />}
/>
// <div style={{ padding: "24px", textAlign: "center" }}>
// <InfoCircleOutlined style={{ fontSize: "48px", color: "#d9d9d9", marginBottom: "16px" }} />
// <Title level={4} type="secondary">
// Estimate not yet scrubbed.
// </Title>
// <Text type="secondary">Run the estimate scrubber to see results here.</Text>
// <EstimateScrubberButton key="es" jobid={job ? job.id : null} job={job ? job : null} />
// </div>
);
}
// Show empty search results state
if (searchText && !filteredItems.length) {
return (
<div style={{ padding: "16px" }}>
<Input
placeholder="Search..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ maxWidth: 400, marginBottom: "24px" }}
/>
<div style={{ padding: "24px", textAlign: "center" }}>
<SearchOutlined style={{ fontSize: "48px", color: "#d9d9d9", marginBottom: "16px" }} />
<Title level={4} type="secondary">
No items match your search
</Title>
<Text type="secondary">Try different keywords or clear the search to see all items.</Text>
</div>
</div>
);
}
return (
<div style={{ padding: "16px" }}>
{Object.keys(groupedItems).length === 0 ? (
<Result title="Estimate Scrubber did not find any issues." status="success" />
) : (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Space>
<Input
placeholder="Search"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ maxWidth: 400 }}
/>
{searchText && (
<div style={{ marginTop: "8px" }}>
<Text type="secondary">
Showing {filteredItems.length} of {esResults.items.length} items
</Text>
</div>
)}
<Button
style={{ float: "right" }}
type="primary"
href={esResults?.pdfUrl}
target="_blank"
disabled={!esResults?.pdfUrl}
>
View PDF
</Button>
<Button
style={{ float: "right" }}
type="link"
href={esResults?.reportIssueUrl}
target="_blank"
disabled={!esResults?.reportIssueUrl}
>
Report Scrubbing Issue
</Button>
</Space>
<Space wrap size={"small"}>
{Object.entries(groupedItems).map(([category, items]) => {
const config = categoryConfig[category] || { color: "default" };
return (
<Tag key={category} color={config.color}>
{category}: {items.length}
</Tag>
);
})}
</Space>
<Collapse defaultActiveKey={sortedCategories} expandIconPosition="end" size="large" items={collapseItems} />
</Space>
)}
</div>
);
}
function ScrubberDescription({text}) {
const orderedListParts = getOrderedListParts(text);
if (!orderedListParts) return <Text>{text}</Text>;
return (
<div>
{orderedListParts.prefix && <Text>{orderedListParts.prefix}</Text>}
<ol
style={{
margin: orderedListParts.prefix ? "8px 0 0 20px" : "0 0 0 20px",
paddingLeft: 16
}}
>
{orderedListParts.items.map((item, index) => (
<li key={`${index}-${item}`}>
<Text>{item}</Text>
</li>
))}
</ol>
</div>
);
}
export function EstimateScrubberResults({bodyshop, jobid, job, esResults}) {
const [searchText, setSearchText] = useState("");
const buttonDisabled = job?.g_bett_amt == null;
const vinLookupUrl = buildVinLookupUrl(job?.v_vin);
const cpsUrl = buildCpsUrl(bodyshop);
// Filter items based on search text
const itemsWithKeys = esResults?.items
? esResults.items.map((item, itemIndex) => ({
...item,
scrubberRowKey: [item.SubCategory, item.L, item.R, item.Anchor, itemIndex].filter(Boolean).join("|")
}))
: [];
const filteredItems = itemsWithKeys.length
? itemsWithKeys.filter((item) => {
if (!searchText.trim()) return true;
const searchLower = searchText.toLowerCase();
return (
(item.L && item.L.toLowerCase().includes(searchLower)) ||
(item.R && item.R.toLowerCase().includes(searchLower))
);
})
: [];
// Group filtered items by category
const groupedItems = filteredItems.length
? _.groupBy(
filteredItems.filter((item) => item.SubCategory !== "In Main Display Group - Table Level"),
"SubCategory"
)
: {};
// Define category colors and priorities
const categoryConfig = {
"Administrative Items": {color: "blue", priority: 1, icon: "📎"},
"Rates Issues": {color: "blue", priority: 2, icon: "💵"},
"MPI Guidelines Items": {color: "blue", priority: 3, icon: "📋"},
"Estimator Recommendations": {color: "blue", priority: 4, icon: "✅"},
"Estimate Parts Found": {color: "blue", priority: 5, icon: "🔧"},
"All Parts Found": {color: "blue", priority: 6, icon: "🔧"}
};
// Sort categories by priority
const sortedCategories = Object.keys(groupedItems).sort((a, b) => {
const priorityA = categoryConfig[a]?.priority || 999;
const priorityB = categoryConfig[b]?.priority || 999;
return priorityA - priorityB;
});
// Define table columns
const columns = [
{
title: "Item",
dataIndex: "L",
key: "item",
width: "25%",
render: (text, record) => (
<Space direction="vertical" size="small">
<Text strong>{text}</Text>
{record.LinkText && record.Anchor && (
<Link href={record.Anchor} target="_blank" rel="noopener noreferrer">
<LinkOutlined/> Learn more
</Link>
)}
</Space>
)
},
{
title: "Description",
dataIndex: "R",
key: "description",
width: "75%",
render: (text) => <ScrubberDescription text={text}/>
}
];
const collapseItems = sortedCategories.map((category) => {
const items = groupedItems[category];
const config = categoryConfig[category] || {color: "default", icon: "📄"};
return {
key: category,
label: (
<Space>
{/* <span style={{ fontSize: "18px" }}>{config.icon}</span> */}
<Text strong>{category}</Text>
<Badge
count={items.length}
style={{
backgroundColor: config.color === "blue" ? "#1890ff" : config.color === "orange" ? "#fa8c16" : "#52c41a"
}}
/>
</Space>
),
children: (
<Table
dataSource={items}
columns={columns}
pagination={false}
size="middle"
rowKey="scrubberRowKey"
style={{marginTop: "12px"}}
scroll={{}}
/>
)
};
});
if (!esResults?.items?.length || job?.id !== esResults?.jobid) {
return (
<Result
status={buttonDisabled ? "error" : "info"}
title={buttonDisabled ? "Unable to scrub." : "Estimate Not Scrubbed"}
subTitle={
buttonDisabled
? "Additional information is required to scrub this estimate. Please reimport the estimate to enable scrubbing."
: "Run the estimate scrubber to see results here."
}
extra={<EstimateScrubberButton key="es" jobid={job ? job.id : null} job={job ? job : null}/>}
/>
// <div style={{ padding: "24px", textAlign: "center" }}>
// <InfoCircleOutlined style={{ fontSize: "48px", color: "#d9d9d9", marginBottom: "16px" }} />
// <Title level={4} type="secondary">
// Estimate not yet scrubbed.
// </Title>
// <Text type="secondary">Run the estimate scrubber to see results here.</Text>
// <EstimateScrubberButton key="es" jobid={job ? job.id : null} job={job ? job : null} />
// </div>
);
}
// Show empty search results state
if (searchText && !filteredItems.length) {
return (
<div style={{padding: "16px"}}>
<Input
placeholder="Search..."
prefix={<SearchOutlined/>}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{maxWidth: 400, marginBottom: "24px"}}
/>
<div style={{padding: "24px", textAlign: "center"}}>
<SearchOutlined style={{fontSize: "48px", color: "#d9d9d9", marginBottom: "16px"}}/>
<Title level={4} type="secondary">
No items match your search
</Title>
<Text type="secondary">Try different keywords or clear the search to see all items.</Text>
</div>
</div>
);
}
return (
<div style={{padding: "16px"}}>
{Object.keys(groupedItems).length === 0 ? (
<Result title="Estimate Scrubber did not find any issues." status="success"/>
) : (
<Space direction="vertical" size="large" style={{width: "100%"}}>
<Space wrap>
<Input
placeholder="Search"
prefix={<SearchOutlined/>}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{maxWidth: 400}}
/>
{searchText && (
<div style={{marginTop: "8px"}}>
<Text type="secondary">
Showing {filteredItems.length} of {esResults.items.length} items
</Text>
</div>
)}
<Button
style={{float: "right"}}
type="primary"
href={esResults?.pdfUrl}
target="_blank"
disabled={!esResults?.pdfUrl}
icon={<FilePdfOutlined/>}
>
View PDF
</Button>
<Button
style={{float: "right"}}
href={vinLookupUrl}
target="_blank"
disabled={!vinLookupUrl}
icon={<SearchOutlined/>}
>
VIN Lookup
</Button>
{cpsUrl && (
<Button style={{float: "right"}} href={cpsUrl} target="_blank" icon={<LinkOutlined/>}>
CPS
</Button>
)}
<Button
style={{float: "right"}}
type="primary"
danger
href={esResults?.reportIssueUrl}
target="_blank"
disabled={!esResults?.reportIssueUrl}
icon={<FlagOutlined/>}
>
Report Scrubbing Issue
</Button>
</Space>
<Space wrap size={"small"}>
{Object.entries(groupedItems).map(([category, items]) => {
const config = categoryConfig[category] || {color: "default"};
return (
<Tag key={category} color={config.color}>
{category}: {items.length}
</Tag>
);
})}
</Space>
<Collapse defaultActiveKey={sortedCategories} expandIconPosition="end" size="large"
items={collapseItems}/>
</Space>
)}
</div>
);
}

View File

@@ -9,102 +9,103 @@ import {
SettingFilled,
SunOutlined
} from "@ant-design/icons";
import { Menu } from "antd";
import {Menu} from "antd";
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import {Link, useLocation} from "react-router-dom";
import {createStructuredSelector} from "reselect";
import ipcTypes from "../../../ipc.types";
import { selectDarkMode } from "../../../redux/user/user.selectors";
import SiderSignOut from "../../molecules/sider-sign-out/sider-sign-out.molecule";
import { connect } from "react-redux";
import { signOutStart, toggleDarkMode } from "../../../redux/user/user.actions";
const { ipcRenderer } = window;
import {selectDarkMode} from "../../../redux/user/user.selectors";
import {connect} from "react-redux";
import {signOutStart, toggleDarkMode} from "../../../redux/user/user.actions";
const {ipcRenderer} = window;
const mapStateToProps = createStructuredSelector({
darkMode: selectDarkMode
darkMode: selectDarkMode
});
const mapDispatchToProps = (dispatch) => ({
toggleDarkMode: () => dispatch(toggleDarkMode()),
signOutStart: () => dispatch(signOutStart())
toggleDarkMode: () => dispatch(toggleDarkMode()),
signOutStart: () => dispatch(signOutStart())
});
export function SiderMenuOrganism({ darkMode, toggleDarkMode, signOutStart }) {
const { pathname } = useLocation();
export function SiderMenuOrganism({darkMode, toggleDarkMode, signOutStart}) {
const {pathname} = useLocation();
return (
<Menu
defaultSelectedKeys={[`/`]}
selectedKeys={[pathname]}
onClick={(e) => {
switch (e.key) {
case "darkmode":
toggleDarkMode();
break;
case "quit":
ipcRenderer.send(ipcTypes.quit);
break;
case "signout":
signOutStart();
break;
default:
break;
}
}}
mode="inline"
items={[
{
key: "/",
icon: <PieChartOutlined />,
label: <Link to="/">Jobs</Link>
},
{
key: "/scan",
icon: <FileAddFilled />,
label: <Link to="/scan">File Scan</Link>
},
{
key: "/reporting",
icon: <BarChartOutlined />,
label: <Link to="/reporting">Reporting</Link>
},
{
key: "/audit",
icon: <AuditOutlined />,
label: <Link to="/audit">Audit</Link>
},
{
key: "/settings",
icon: <SettingFilled />,
label: <Link to="/settings">Settings</Link>
},
{ type: "divider" },
{
key: "signout",
icon: <LogoutOutlined style={{ color: "tomato" }} />,
label: "Sign out"
},
{
key: "quit",
icon: <CloseOutlined style={{ color: "tomato" }} />,
label: "Quit"
},
{
key: "darkmode",
icon: darkMode ? <SunOutlined /> : <MoonOutlined />,
label: darkMode ? "Light Mode" : "Dark Mode"
}
// ...(process.env.NODE_ENV !== "production"
// ? [
// {
// key: "/admin",
// icon: <AlertOutlined />,
// label: <Link to="/admin">ADMIN</Link>
// }
// ]
// : [])
]}
/>
);
return (
<Menu
defaultSelectedKeys={[`/`]}
selectedKeys={[pathname]}
onClick={(e) => {
switch (e.key) {
case "darkmode":
ipcRenderer.send(ipcTypes.store.set, {darkMode: !darkMode});
toggleDarkMode();
break;
case "quit":
ipcRenderer.send(ipcTypes.quit);
break;
case "signout":
signOutStart();
break;
default:
break;
}
}}
mode="inline"
items={[
{
key: "/",
icon: <PieChartOutlined/>,
label: <Link to="/">Jobs</Link>
},
{
key: "/scan",
icon: <FileAddFilled/>,
label: <Link to="/scan">File Scan</Link>
},
{
key: "/reporting",
icon: <BarChartOutlined/>,
label: <Link to="/reporting">Reporting</Link>
},
{
key: "/audit",
icon: <AuditOutlined/>,
label: <Link to="/audit">Audit</Link>
},
{
key: "/settings",
icon: <SettingFilled/>,
label: <Link to="/settings">Settings</Link>
},
{type: "divider"},
{
key: "signout",
icon: <LogoutOutlined style={{color: "tomato"}}/>,
label: "Sign out"
},
{
key: "quit",
icon: <CloseOutlined style={{color: "tomato"}}/>,
label: "Quit"
},
{
key: "darkmode",
icon: darkMode ? <SunOutlined/> : <MoonOutlined/>,
label: darkMode ? "Light Mode" : "Dark Mode"
}
// ...(process.env.NODE_ENV !== "production"
// ? [
// {
// key: "/admin",
// icon: <AlertOutlined />,
// label: <Link to="/admin">ADMIN</Link>
// }
// ]
// : [])
]}
/>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(SiderMenuOrganism);

View File

@@ -1,10 +1,9 @@
import { Alert, Layout } from "antd";
import {Alert, Layout} from "antd";
import React from "react";
import { connect } from "react-redux";
import { Routes } from "react-router-dom";
import { Route } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectDarkMode } from "../../../redux/user/user.selectors";
import {connect} from "react-redux";
import {Route, Routes} from "react-router-dom";
import {createStructuredSelector} from "reselect";
import {selectBodyshop, selectDarkMode} from "../../../redux/user/user.selectors";
import ErrorResultAtom from "../../atoms/error-result/error-result.atom";
import ReleaseNotes from "../../molecules/release-notes/release-notes.molecule";
import NotificationModalOrganism from "../../organisms/notification-modal/notification-modal.organism";
@@ -17,47 +16,48 @@ import SettingsPage from "../settings/settings.page";
import AuditPage from "../audit/audit.page";
import AdminPage from "../admin/admin.page";
const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, darkMode: selectDarkMode });
const mapStateToProps = createStructuredSelector({bodyshop: selectBodyshop, darkMode: selectDarkMode});
const mapDispatchToProps = (dispatch) => ({});
export function RoutesPage({ bodyshop, darkMode }) {
if (bodyshop === false)
return (
<ErrorResultAtom
title="No shop access"
errorMessage="You do not currently have access to any shop. Please reach out to technical support."
/>
);
return (
<Layout style={{ height: "100vh" }} hasSider>
<Layout.Sider style={{ background: !darkMode && "#fff" }} collapsible defaultCollapsed="true">
<SiderMenuOrganism />
</Layout.Sider>
<Layout style={{}}>
<Layout.Content style={{ marginLeft: "1rem", paddingTop: "1rem", height: "100%", boxSizing: "border-box" }}>
<NotificationModalOrganism />
{bodyshop?.ins_rule_set === "SGI" && (
<Alert
message="SGI has not yet released eligibility rules. MPI eligibility rules have been applied and may be incorrect."
type="warning"
showIcon
style={{ marginBottom: "1rem" }}
export function RoutesPage({bodyshop, darkMode}) {
if (bodyshop === false)
return (
<ErrorResultAtom
title="No shop access"
errorMessage="You do not currently have access to any shop. Please reach out to technical support."
/>
)}
<Routes>
<Route exact path="/settings" element={<SettingsPage />} />
<Route exact path="/reporting" element={<ReportingPage />} />
<Route exact path="/audit" element={<AuditPage />} />
<Route exact path="/scan" element={<ScanPage />} />
<Route exact path="/admin" element={<AdminPage />} />
<Route exact path="/" element={<JobsPage />} />
</Routes>
</Layout.Content>
<ReleaseNotes />
<UpdateManagerOrganism />
</Layout>
</Layout>
);
);
return (
<Layout style={{height: "100vh", overflow: "hidden"}} hasSider>
<Layout.Sider style={{background: !darkMode && "#fff"}} collapsible defaultCollapsed="true">
<SiderMenuOrganism/>
</Layout.Sider>
<Layout style={{minWidth: 0, overflow: "hidden"}}>
<Layout.Content style={{padding: "1rem", height: "100%", boxSizing: "border-box", overflowX: "hidden"}}>
<NotificationModalOrganism/>
{bodyshop?.ins_rule_set === "SGI" && (
<Alert
message="SGI has not yet released eligibility rules. MPI eligibility rules have been applied and may be incorrect."
type="warning"
showIcon
style={{marginBottom: "1rem"}}
/>
)}
<Routes>
<Route exact path="/settings" element={<SettingsPage/>}/>
<Route exact path="/reporting" element={<ReportingPage/>}/>
<Route exact path="/audit" element={<AuditPage/>}/>
<Route exact path="/scan" element={<ScanPage/>}/>
<Route exact path="/admin" element={<AdminPage/>}/>
<Route exact path="/" element={<JobsPage/>}/>
</Routes>
</Layout.Content>
<ReleaseNotes/>
<UpdateManagerOrganism/>
</Layout>
</Layout>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(RoutesPage);

View File

@@ -1,4 +1,4 @@
import { Card, Col, Row, Space, Typography } from "antd";
import { Card, Space, Typography, theme } from "antd";
import React, { useEffect } from "react";
import ipcTypes from "../../../ipc.types";
import NotificationsToggleAtom from "../../atoms/notifications-toggle/notifications-toggle.atom";
@@ -11,48 +11,63 @@ import "./settings.page.styles.scss";
const { ipcRenderer } = window;
export default function SettingsPage() {
const { token } = theme.useToken();
useEffect(() => {
ipcRenderer.send(ipcTypes.store.getAll);
}, []);
return (
<div className="settings-page-container">
<div
className="settings-page-container"
style={{
"--settings-hero-bg-start": token.colorBgContainer,
"--settings-hero-bg-mid": token.colorFillQuaternary,
"--settings-hero-bg-end": token.colorFillTertiary,
"--settings-border": token.colorBorderSecondary,
"--settings-shadow": token.boxShadowTertiary,
"--settings-control-bg": token.colorFillQuaternary,
"--settings-success-bg": token.colorSuccessBg,
"--settings-success-border": token.colorSuccessBorder,
"--settings-success-text": token.colorSuccessText,
"--settings-error-bg": token.colorErrorBg,
"--settings-error-border": token.colorErrorBorder,
"--settings-error-text": token.colorErrorText
}}
>
<div className="settings-page__hero">
<Typography.Title level={3}>Settings</Typography.Title>
<Typography.Paragraph type="secondary">
Manage watcher behavior, notifications, and shop defaults in one compact workspace.
</Typography.Paragraph>
<div className="settings-page__hero-copy">
<Typography.Title level={3}>Settings</Typography.Title>
<Typography.Paragraph type="secondary">
Manage watcher behavior, notifications, and shop defaults in one compact workspace.
</Typography.Paragraph>
</div>
<div className="settings-page__hero-status">
<WatcherManagerOrganism />
</div>
</div>
<Row gutter={[16, 16]} align="top">
<Col xs={24} xl={16}>
<Space direction="vertical" size={16} className="settings-page__content-stack">
<Card variant="borderless" className="settings-page__card settings-page__card--shop">
<ShopSettingsOrganism />
</Card>
<div className="settings-page__secondary-grid">
<Card variant="borderless" className="settings-page__card settings-page__card--filepaths">
<FilePathsListOrganism />
</Card>
</Col>
<Col xs={24} xl={8}>
<Space direction="vertical" size={16} className="settings-page__side-stack">
<Card variant="borderless" className="settings-page__card">
<WatcherManagerOrganism />
</Card>
<Card variant="borderless" className="settings-page__card settings-page__card--automation">
<Typography.Title level={5}>Automation</Typography.Title>
<Typography.Paragraph type="secondary">
Fine-tune polling, startup behavior, and desktop notifications.
</Typography.Paragraph>
<Space direction="vertical" size={10} className="settings-page__controls">
<WatcherPollingMolecule />
<NotificationsToggleAtom />
<WatcherStartupAtom />
</Space>
</Card>
</Space>
</Col>
</Row>
<Card variant="borderless" className="settings-page__card settings-page__card--shop">
<ShopSettingsOrganism />
</Card>
<Card variant="borderless" className="settings-page__card settings-page__card--automation">
<Typography.Title level={3}>Automation</Typography.Title>
<Typography.Paragraph type="secondary">
Fine-tune polling, startup behavior, and desktop notifications.
</Typography.Paragraph>
<Space direction="vertical" size={10} className="settings-page__controls">
<WatcherPollingMolecule />
<NotificationsToggleAtom />
<WatcherStartupAtom />
</Space>
</Card>
</div>
</Space>
</div>
);
}

View File

@@ -8,11 +8,20 @@
}
.settings-page__hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 24px;
border-radius: 18px;
background: linear-gradient(135deg, #ffffff 0%, #f5f9ff 55%, #eef4ff 100%);
border: 1px solid #dbe7ff;
box-shadow: 0 10px 24px rgba(23, 43, 77, 0.06);
background: linear-gradient(
135deg,
var(--settings-hero-bg-start, #ffffff) 0%,
var(--settings-hero-bg-mid, #f5f9ff) 55%,
var(--settings-hero-bg-end, #eef4ff) 100%
);
border: 1px solid var(--settings-border, #dbe7ff);
box-shadow: var(--settings-shadow, 0 10px 24px rgba(23, 43, 77, 0.06));
.ant-typography {
margin-bottom: 0;
@@ -23,18 +32,47 @@
}
}
.settings-page__side-stack {
.settings-page__hero-copy {
flex: 1 1 360px;
min-width: 0;
}
.settings-page__hero-status {
flex: 0 1 520px;
min-width: 360px;
}
.settings-page__hero-status .settings-watcher-manager {
gap: 8px;
}
.settings-page__hero-status .settings-watcher-manager__content {
justify-content: flex-end;
}
.settings-page__content-stack {
width: 100%;
}
.settings-page__side-stack > .ant-space-item,
.settings-page__content-stack > .ant-space-item,
.settings-page__controls > .ant-space-item {
width: 100%;
}
.settings-page__secondary-grid {
display: grid;
grid-template-columns: minmax(420px, 0.38fr) minmax(0, 1fr);
gap: 16px;
width: 100%;
}
.settings-page__secondary-grid .settings-page__card {
height: 100%;
}
.settings-page__card {
border-radius: 18px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
box-shadow: var(--settings-shadow, 0 10px 24px rgba(15, 23, 42, 0.06));
.ant-card-body {
padding: 18px 20px;
@@ -64,8 +102,8 @@
.settings-page__controls > .ant-space-item > * {
padding: 10px 12px;
border-radius: 12px;
background: #fafcff;
border: 1px solid #edf2ff;
background: var(--settings-control-bg, #fafcff);
border: 1px solid var(--settings-border, #edf2ff);
}
.settings-page__controls > .ant-space-item > * > div {
@@ -105,9 +143,9 @@
.settings-filepaths__list {
overflow: hidden;
border: 1px solid #edf2ff;
border: 1px solid var(--settings-border, #edf2ff);
border-radius: 14px;
background: #fafcff;
background: var(--settings-control-bg, #fafcff);
}
.settings-filepaths__list .ant-list-item {
@@ -155,15 +193,19 @@
}
.settings-watcher-status--started {
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
color: var(--settings-success-text, #389e0d);
background: var(--settings-success-bg, #f6ffed);
border-color: var(--settings-success-border, #b7eb8f);
}
.settings-watcher-status--stopped {
color: #cf1322;
background: #fff1f0;
border-color: #ffa39e;
color: var(--settings-error-text, #cf1322);
background: var(--settings-error-bg, #fff1f0);
border-color: var(--settings-error-border, #ffa39e);
}
.settings-watcher-status .ant-typography {
color: inherit;
}
.settings-watcher-status__error {
@@ -176,7 +218,23 @@
padding-bottom: 16px;
}
.settings-page__hero,
.settings-page__hero {
align-items: stretch;
flex-direction: column;
gap: 16px;
padding: 20px;
}
.settings-page__hero-status {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
.settings-page__secondary-grid {
grid-template-columns: 1fr;
}
.settings-page__card .ant-card-body {
padding: 20px;
}

View File

@@ -1,4 +1,4 @@
import { antdNotification as notification } from "../util/antdFeedback";
import {antdNotification as notification} from "../util/antdFeedback";
import ipcTypes from "../ipc.types";
import {
setReleaseNotes,
@@ -9,87 +9,91 @@ import {
setWatchedPaths,
setWatcherStatus
} from "../redux/application/application.actions";
import { calculateAudit, setAuditError } from "../redux/reporting/reporting.actions";
import { setScanEstimateList } from "../redux/scan/scan.actions";
import { store } from "../redux/store";
import { signOutStart } from "../redux/user/user.actions";
import { GetR4PDateWithClaim, UpsertEstimate } from "./ipc-estimate-utils";
const { ipcRenderer } = window;
import {calculateAudit, setAuditError} from "../redux/reporting/reporting.actions";
import {setScanEstimateList} from "../redux/scan/scan.actions";
import {store} from "../redux/store";
import {setDarkMode, signOutStart} from "../redux/user/user.actions";
import {GetR4PDateWithClaim, UpsertEstimate} from "./ipc-estimate-utils";
const {ipcRenderer} = window;
console.log("----Initializing IPC Listeners in React App.");
ipcRenderer.on("test-toRenderer", (event, obj) => {
console.log("test-toRenderer", obj);
console.log("test-toRenderer", obj);
});
ipcRenderer.on(ipcTypes.fileWatcher.toRenderer.filepathsList, (event, obj) => {
store.dispatch(setWatchedPaths(obj));
store.dispatch(setWatchedPaths(obj));
});
//Filewatcher Status Section
ipcRenderer.on(ipcTypes.fileWatcher.toRenderer.startSuccess, (event, obj) => {
store.dispatch(setWatcherStatus("Started"));
store.dispatch(setWatcherStatus("Started"));
});
ipcRenderer.on(ipcTypes.fileWatcher.toRenderer.stopSuccess, (event, obj) => {
store.dispatch(setWatcherStatus("Stopped"));
store.dispatch(setWatcherStatus("Stopped"));
});
ipcRenderer.on(ipcTypes.fileWatcher.toRenderer.error, (event, obj) => {
store.dispatch(setWatcherStatus(obj));
store.dispatch(setWatcherStatus(obj));
});
//Estimate Section
ipcRenderer.on(ipcTypes.estimate.toRenderer.getCloseDate, async (event, { filepath, clm_no }) => {
const close_date = await GetR4PDateWithClaim(clm_no);
ipcRenderer.send(ipcTypes.app.toMain.importJob, {
filepath,
close_date
});
ipcRenderer.on(ipcTypes.estimate.toRenderer.getCloseDate, async (event, {filepath, clm_no}) => {
const close_date = await GetR4PDateWithClaim(clm_no);
ipcRenderer.send(ipcTypes.app.toMain.importJob, {
filepath,
close_date
});
});
ipcRenderer.on(ipcTypes.estimate.toRenderer.estimateDecodeSuccess, async (event, obj) => {
await UpsertEstimate(obj);
await UpsertEstimate(obj);
});
ipcRenderer.on(ipcTypes.store.response, (event, obj) => {
store.dispatch(setSettings(obj));
store.dispatch(setSettings(obj));
if (Object.prototype.hasOwnProperty.call(obj, "darkMode")) {
store.dispatch(setDarkMode(obj.darkMode));
}
});
//FileScan Section
ipcRenderer.on(ipcTypes.fileScan.toRenderer.scanFilePathsResponse, async (event, listOfEstimates) => {
store.dispatch(setScanEstimateList(listOfEstimates));
store.dispatch(setScanEstimateList(listOfEstimates));
});
ipcRenderer.on(ipcTypes.app.toRenderer.updateAvailable, async (event, updateInfo) => {
store.dispatch(setUpdateAvailable(updateInfo));
store.dispatch(setUpdateAvailable(updateInfo));
});
ipcRenderer.on(ipcTypes.app.toRenderer.downloadProgress, async (event, progress) => {
store.dispatch(setUpdateProgress(progress));
store.dispatch(setUpdateProgress(progress));
});
ipcRenderer.on(ipcTypes.app.toRenderer.signOut, async (event, progress) => {
store.dispatch(signOutStart());
store.dispatch(signOutStart());
});
ipcRenderer.on(ipcTypes.app.toRenderer.setReleaseNotes, async (event, releaseNotes) => {
store.dispatch(setReleaseNotes(releaseNotes));
store.dispatch(setReleaseNotes(releaseNotes));
});
ipcRenderer.on(ipcTypes.app.toRenderer.appVersion, async (event, appversion) => {
window.$crisp.push(["set", "session:data", [[["rps-version", appversion]]]]);
window.$crisp.push(["set", "session:data", [[["rps-version", appversion]]]]);
});
//Handle Autdit
ipcRenderer.on(ipcTypes.audit.toRenderer.auditClaimsArray, async (event, claimsArray) => {
store.dispatch(calculateAudit(claimsArray));
store.dispatch(calculateAudit(claimsArray));
});
ipcRenderer.on(ipcTypes.audit.toRenderer.auditError, async (event, error) => {
store.dispatch(setAuditError(error));
store.dispatch(setAuditError(error));
});
ipcRenderer.on(ipcTypes.app.toRenderer.scrubResults, async (event, results) => {
store.dispatch(setScrubResults(results));
store.dispatch(setScrubResults(results));
});
ipcRenderer.on(ipcTypes.app.toRenderer.scrubError, async (event, { message }) => {
notification.open({ type: "error", message: "Estimate Scrubber Error", description: message });
ipcRenderer.on(ipcTypes.app.toRenderer.scrubError, async (event, {message}) => {
notification.open({type: "error", message: "Estimate Scrubber Error", description: message});
});

View File

@@ -97,6 +97,11 @@ export const checkForNotification = () => ({
type: UserActionTypes.CHECK_FOR_NOTIFICATION
});
export const setDarkMode = (darkMode) => ({
type: UserActionTypes.SET_DARK_MODE,
payload: darkMode
});
export const toggleDarkMode = () => ({
type: UserActionTypes.TOGGLE_DARK_MODE
});

View File

@@ -82,6 +82,11 @@ const userReducer = (state = INITIAL_STATE, action) => {
...action.payload //Spread current user details in.
}
};
case UserActionTypes.SET_DARK_MODE:
return {
...state,
darkMode: action.payload
};
case UserActionTypes.TOGGLE_DARK_MODE:
return {
...state,

View File

@@ -30,6 +30,7 @@ const UserActionTypes = {
CHECK_FOR_NOTIFICATION: "CHECK_FOR_NOTIFICATION",
SET_NOTIFICATIONS: "SET_NOTIFICATIONS",
SET_TARGETS: "SET_TARGETS",
SET_DARK_MODE: "SET_DARK_MODE",
TOGGLE_DARK_MODE: "TOGGLE_DARK_MODE"
};
export default UserActionTypes;