Compare commits
94 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5997d0b8f | ||
|
|
19f918b695 | ||
|
|
b5b56f12aa | ||
|
|
76b15f0521 | ||
|
|
03e06cfd96 | ||
|
|
0622696650 | ||
|
|
51fca7a63c | ||
|
|
9d9e626cfe | ||
|
|
285043a6ba | ||
|
|
5812d53efc | ||
|
|
b2231007b6 | ||
|
|
fcc05156bc | ||
|
|
31c6e7c0e5 | ||
|
|
f0f5c09fd7 | ||
|
|
debc67cc49 | ||
|
|
281fe02820 | ||
|
|
34c9a3854c | ||
|
|
98781a76e6 | ||
|
|
172bbecff7 | ||
|
|
3f03157834 | ||
|
|
782fa8a1c7 | ||
|
|
45688c0dde | ||
|
|
aebd8da4ae | ||
|
|
b4e8a80735 | ||
|
|
73ba95a240 | ||
|
|
8af8c8039c | ||
|
|
3a1d10b0d1 | ||
|
|
e6071709be | ||
|
|
c95c11fd0e | ||
|
|
1351fbb814 | ||
|
|
dcd3a078ef | ||
|
|
bb8e140f6e | ||
|
|
bf11e10676 | ||
|
|
92e6bdf2a2 | ||
|
|
a02e336d73 | ||
|
|
7ec8a73c30 | ||
|
|
e669c19b98 | ||
|
|
5c55c0c74b | ||
|
|
f1f705903a | ||
|
|
6551be2d92 | ||
|
|
48e59fe849 | ||
|
|
7991192496 | ||
|
|
05cd60c2a1 | ||
|
|
26fc76a767 | ||
|
|
49816d5d43 | ||
|
|
b9b3e2c2aa | ||
|
|
e3c02f94f1 | ||
|
|
490dd662d5 | ||
|
|
8d00fc29d1 | ||
|
|
784378a999 | ||
|
|
f04f48f593 | ||
|
|
721e9bc464 | ||
|
|
76c828a1c9 | ||
|
|
7e5363f911 | ||
|
|
0d502d4dd4 | ||
|
|
f5b16394f9 | ||
|
|
7132465945 | ||
|
|
a873a2573a | ||
|
|
ff24db6561 | ||
|
|
da26954c3b | ||
|
|
6991cf60e5 | ||
|
|
818aedf04f | ||
|
|
1cb6834207 | ||
|
|
8577929bd4 | ||
|
|
f44121e06b | ||
|
|
faf9fb75c5 | ||
|
|
8980d3716b | ||
|
|
764ec5f8f9 | ||
|
|
a7a7551dae | ||
|
|
571536a7ec | ||
|
|
20e56fff6a | ||
|
|
8f132ca14d | ||
|
|
99c002dac1 | ||
|
|
0cd30ccdec | ||
|
|
acd69276a5 | ||
|
|
faf5878bdf | ||
|
|
f56a540b2f | ||
|
|
e251e5f8f6 | ||
|
|
5a55798d2d | ||
|
|
c9e41ba72a | ||
|
|
522f2b9e26 | ||
|
|
be9267ddd4 | ||
|
|
e4a79b51c7 | ||
|
|
47a9a963fa | ||
|
|
f3c7a831a1 | ||
|
|
6ac9310e81 | ||
|
|
b91e65be0e | ||
|
|
3f2358e30c | ||
|
|
ce02d90c3c | ||
|
|
95a71bea6e | ||
|
|
3b27120d77 | ||
|
|
f350163056 | ||
|
|
57cfecb7b8 | ||
|
|
5f8a08b0a7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -142,8 +142,6 @@ docker_data
|
||||
/CLAUDE.md
|
||||
/COPILOT.md
|
||||
/GEMINI.md
|
||||
/_reference/select-component-test-plan.md
|
||||
|
||||
/.cursorrules
|
||||
/AGENTS.md
|
||||
/AI_CONTEXT.md
|
||||
@@ -151,4 +149,3 @@ docker_data
|
||||
/COPILOT.md
|
||||
/.github/copilot-instructions.md
|
||||
/GEMINI.md
|
||||
/_reference/select-component-test-plan.md
|
||||
|
||||
@@ -1,7 +1,62 @@
|
||||
This will connect to your dockers local stack session and render the email in HTML.
|
||||
This app connects to your Docker LocalStack endpoints and gives you a compact inspector for:
|
||||
|
||||
- SES generated emails
|
||||
- CloudWatch log groups, streams, and recent events
|
||||
- Secrets Manager secrets and values
|
||||
- S3 buckets and object previews
|
||||
|
||||
```shell
|
||||
npm start
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```shell
|
||||
node index.js
|
||||
```
|
||||
|
||||
http://localhost:3334
|
||||
Open: http://localhost:3334
|
||||
|
||||
Features:
|
||||
|
||||
- SES email workspace with manual refresh, live refresh, search, HTML/text/raw views,
|
||||
attachment downloads, and new-message highlighting
|
||||
- CloudWatch Logs workspace with log group selection, stream filtering, adjustable time window,
|
||||
adjustable event limit, live refresh, in-browser log search, log-level highlighting, wrap toggle,
|
||||
and optional tail-to-newest mode
|
||||
- Secrets Manager workspace with live refresh, search, expandable secret metadata, lazy-loaded
|
||||
secret values, masked-by-default secret viewing, and quick copy actions
|
||||
- S3 Explorer workspace with bucket selection, prefix filtering, object search, lazy object
|
||||
previews,
|
||||
object key/URI copy actions, and downloads
|
||||
- Shared LocalStack service health strip plus a reset action for clearing saved viewer state
|
||||
- Compact single-page UI for switching between the local stack tools you use most
|
||||
|
||||
Code layout:
|
||||
|
||||
- `index.js`: small Express bootstrap and route registration
|
||||
- `server/config.js`: LocalStack endpoints, defaults, and AWS client setup
|
||||
- `server/localstack-service.js`: SES, Logs, Secrets, and S3 data loading helpers
|
||||
- `server/page.js`: server-rendered HTML shell, CSS, and client config payload
|
||||
- `public/client-app.js`: browser-side UI state, rendering, refresh logic, and interactions
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
```shell
|
||||
PORT=3334
|
||||
SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses
|
||||
SES_VIEWER_REFRESH_MS=10000
|
||||
SES_VIEWER_FETCH_TIMEOUT_MS=5000
|
||||
CLOUDWATCH_VIEWER_ENDPOINT=http://localhost:4566
|
||||
CLOUDWATCH_VIEWER_REGION=ca-central-1
|
||||
CLOUDWATCH_VIEWER_LOG_GROUP=development
|
||||
CLOUDWATCH_VIEWER_WINDOW_MS=900000
|
||||
CLOUDWATCH_VIEWER_LIMIT=200
|
||||
SECRETS_VIEWER_ENDPOINT=http://localhost:4566
|
||||
SECRETS_VIEWER_REGION=ca-central-1
|
||||
S3_VIEWER_ENDPOINT=http://localhost:4566
|
||||
S3_VIEWER_REGION=ca-central-1
|
||||
S3_VIEWER_BUCKET=
|
||||
S3_VIEWER_PREVIEW_BYTES=262144
|
||||
S3_VIEWER_IMAGE_PREVIEW_BYTES=1048576
|
||||
```
|
||||
|
||||
@@ -1,96 +1,342 @@
|
||||
// index.js
|
||||
|
||||
import express from "express";
|
||||
import fetch from "node-fetch";
|
||||
import { simpleParser } from "mailparser";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
CLOUDWATCH_DEFAULT_LIMIT,
|
||||
CLOUDWATCH_DEFAULT_WINDOW_MS,
|
||||
CLOUDWATCH_ENDPOINT,
|
||||
CLOUDWATCH_REGION,
|
||||
DEFAULT_REFRESH_MS,
|
||||
PORT,
|
||||
S3_ENDPOINT,
|
||||
S3_REGION,
|
||||
SES_ENDPOINT,
|
||||
SECRETS_ENDPOINT,
|
||||
SECRETS_REGION
|
||||
} from "./server/config.js";
|
||||
import { getClientConfig, renderHtml } from "./server/page.js";
|
||||
import {
|
||||
buildAttachmentDisposition,
|
||||
buildInlineDisposition,
|
||||
clampNumber,
|
||||
findSesMessageById,
|
||||
loadLogEvents,
|
||||
loadLogGroups,
|
||||
loadLogStreams,
|
||||
loadMessageAttachment,
|
||||
loadMessages,
|
||||
loadS3Buckets,
|
||||
loadS3ObjectDownload,
|
||||
loadS3ObjectPreview,
|
||||
loadS3Objects,
|
||||
loadSecretValue,
|
||||
loadSecrets,
|
||||
loadServiceHealthSummary
|
||||
} from "./server/localstack-service.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3334;
|
||||
const CLIENT_APP_PATH = new URL("./public/client-app.js", import.meta.url);
|
||||
const CLIENT_APP_SOURCE = readFileSync(CLIENT_APP_PATH, "utf8");
|
||||
|
||||
app.get("/", async (req, res) => {
|
||||
app.use((req, res, next) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.type("html").send(renderHtml());
|
||||
});
|
||||
|
||||
app.get("/app.js", (req, res) => {
|
||||
res.type("application/javascript").send(`${CLIENT_APP_SOURCE}\n\nclientApp(${JSON.stringify(getClientConfig())});\n`);
|
||||
});
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
endpoint: SES_ENDPOINT,
|
||||
endpoints: {
|
||||
ses: SES_ENDPOINT,
|
||||
cloudWatchLogs: CLOUDWATCH_ENDPOINT,
|
||||
secretsManager: SECRETS_ENDPOINT,
|
||||
s3: S3_ENDPOINT
|
||||
},
|
||||
port: PORT,
|
||||
defaultRefreshMs: DEFAULT_REFRESH_MS
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/service-health", async (req, res) => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:4566/_aws/ses");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
const messagesHtml = await parseMessages(data.messages);
|
||||
res.send(renderHtml(messagesHtml));
|
||||
res.json(await loadServiceHealthSummary());
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
res.status(500).send("Error fetching messages");
|
||||
console.error("Error fetching service health:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch LocalStack service health",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function parseMessages(messages) {
|
||||
const parsedMessages = await Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData);
|
||||
return `
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">To:</span> ${parsed.to.text || "No To Address"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Subject:</span> ${parsed.subject || "No Subject"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
</div>
|
||||
<div class="prose">${parsed.html || parsed.textAsHtml || "No HTML content available"}</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing email:", error);
|
||||
return `
|
||||
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
<div class="text-red-500">Error parsing email content</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
);
|
||||
return parsedMessages.join("");
|
||||
}
|
||||
app.get("/api/messages", async (req, res) => {
|
||||
try {
|
||||
res.json(await loadMessages());
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch messages from LocalStack SES",
|
||||
details: error.message,
|
||||
endpoint: SES_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function renderHtml(messagesHtml) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Messages Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
||||
<div id="messages-container">${messagesHtml}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
app.get("/api/messages/:id/raw", async (req, res) => {
|
||||
try {
|
||||
const message = await findSesMessageById(req.params.id);
|
||||
|
||||
if (!message) {
|
||||
res.status(404).type("text/plain").send("Message not found");
|
||||
return;
|
||||
}
|
||||
|
||||
res.type("text/plain").send(message.RawData || "");
|
||||
} catch (error) {
|
||||
console.error("Error fetching raw message:", error);
|
||||
res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/messages/:id/attachments/:index", async (req, res) => {
|
||||
try {
|
||||
const attachmentIndex = Number.parseInt(req.params.index, 10);
|
||||
|
||||
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) {
|
||||
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer");
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = await loadMessageAttachment(req.params.id, attachmentIndex);
|
||||
|
||||
if (!attachment) {
|
||||
res.status(404).type("text/plain").send("Attachment not found");
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", attachment.contentType);
|
||||
res.setHeader("Content-Disposition", buildAttachmentDisposition(attachment.filename));
|
||||
res.setHeader("Content-Length", String(attachment.content.length));
|
||||
res.send(attachment.content);
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/logs/groups", async (req, res) => {
|
||||
try {
|
||||
const groups = await loadLogGroups();
|
||||
res.json({
|
||||
endpoint: CLOUDWATCH_ENDPOINT,
|
||||
region: CLOUDWATCH_REGION,
|
||||
groups
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching log groups:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch CloudWatch log groups from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: CLOUDWATCH_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/logs/streams", async (req, res) => {
|
||||
try {
|
||||
const logGroupName = String(req.query.group || "");
|
||||
|
||||
if (!logGroupName) {
|
||||
res.status(400).json({ error: "Query parameter 'group' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
logGroupName,
|
||||
streams: await loadLogStreams(logGroupName)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching log streams:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch CloudWatch log streams from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: CLOUDWATCH_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/logs/events", async (req, res) => {
|
||||
try {
|
||||
const logGroupName = String(req.query.group || "");
|
||||
const logStreamName = String(req.query.stream || "");
|
||||
const windowMs = clampNumber(req.query.windowMs, CLOUDWATCH_DEFAULT_WINDOW_MS, 60 * 1000, 24 * 60 * 60 * 1000);
|
||||
const limit = clampNumber(req.query.limit, CLOUDWATCH_DEFAULT_LIMIT, 25, 500);
|
||||
|
||||
if (!logGroupName) {
|
||||
res.status(400).json({ error: "Query parameter 'group' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await loadLogEvents({ logGroupName, logStreamName, windowMs, limit }));
|
||||
} catch (error) {
|
||||
console.error("Error fetching log events:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch CloudWatch log events from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: CLOUDWATCH_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/secrets", async (req, res) => {
|
||||
try {
|
||||
res.json(await loadSecrets());
|
||||
} catch (error) {
|
||||
console.error("Error fetching secrets:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch Secrets Manager secrets from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: SECRETS_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/secrets/value", async (req, res) => {
|
||||
try {
|
||||
const secretId = String(req.query.id || "");
|
||||
|
||||
if (!secretId) {
|
||||
res.status(400).json({ error: "Query parameter 'id' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await loadSecretValue(secretId));
|
||||
} catch (error) {
|
||||
if (error?.name === "ResourceNotFoundException") {
|
||||
res.status(404).json({
|
||||
error: "Secret not found",
|
||||
details: error.message,
|
||||
endpoint: SECRETS_ENDPOINT
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Error fetching secret value:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch Secrets Manager value from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: SECRETS_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/s3/buckets", async (req, res) => {
|
||||
try {
|
||||
res.json(await loadS3Buckets());
|
||||
} catch (error) {
|
||||
console.error("Error fetching S3 buckets:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch S3 buckets from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: S3_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/s3/objects", async (req, res) => {
|
||||
try {
|
||||
const bucket = String(req.query.bucket || "");
|
||||
const prefix = String(req.query.prefix || "");
|
||||
|
||||
if (!bucket) {
|
||||
res.status(400).json({ error: "Query parameter 'bucket' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await loadS3Objects({ bucket, prefix }));
|
||||
} catch (error) {
|
||||
console.error("Error fetching S3 objects:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch S3 objects from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: S3_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/s3/object", async (req, res) => {
|
||||
try {
|
||||
const bucket = String(req.query.bucket || "");
|
||||
const key = String(req.query.key || "");
|
||||
|
||||
if (!bucket || !key) {
|
||||
res.status(400).json({ error: "Query parameters 'bucket' and 'key' are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await loadS3ObjectPreview({ bucket, key }));
|
||||
} catch (error) {
|
||||
if (error?.name === "NoSuchKey" || error?.name === "NotFound") {
|
||||
res.status(404).json({
|
||||
error: "Object not found",
|
||||
details: error.message,
|
||||
endpoint: S3_ENDPOINT
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Error fetching S3 object preview:", error);
|
||||
res.status(502).json({
|
||||
error: "Unable to fetch S3 object preview from LocalStack",
|
||||
details: error.message,
|
||||
endpoint: S3_ENDPOINT
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/s3/download", async (req, res) => {
|
||||
try {
|
||||
const bucket = String(req.query.bucket || "");
|
||||
const key = String(req.query.key || "");
|
||||
const inline = String(req.query.inline || "") === "1";
|
||||
|
||||
if (!bucket || !key) {
|
||||
res.status(400).type("text/plain").send("Query parameters 'bucket' and 'key' are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const object = await loadS3ObjectDownload({ bucket, key });
|
||||
|
||||
res.setHeader("Content-Type", object.contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
inline ? buildInlineDisposition(object.filename) : buildAttachmentDisposition(object.filename)
|
||||
);
|
||||
res.setHeader("Content-Length", String(object.content.length));
|
||||
res.send(object.content);
|
||||
} catch (error) {
|
||||
if (error?.name === "NoSuchKey" || error?.name === "NotFound") {
|
||||
res.status(404).type("text/plain").send("Object not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Error downloading S3 object:", error);
|
||||
res.status(502).type("text/plain").send(`Unable to download S3 object: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
console.log(`LocalStack inspector is running on http://localhost:${PORT}`);
|
||||
console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`);
|
||||
console.log(`Watching LocalStack CloudWatch Logs endpoint at ${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`);
|
||||
console.log(`Watching LocalStack Secrets Manager endpoint at ${SECRETS_ENDPOINT} (${SECRETS_REGION})`);
|
||||
console.log(`Watching LocalStack S3 endpoint at ${S3_ENDPOINT} (${S3_REGION})`);
|
||||
});
|
||||
|
||||
1764
_reference/localEmailViewer/package-lock.json
generated
1764
_reference/localEmailViewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,17 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"start": "node index.js",
|
||||
"check": "node --check index.js && node --check public/client-app.js && node --check server/config.js && node --check server/localstack-service.js && node --check server/page.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"description": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1012.0",
|
||||
"@aws-sdk/client-s3": "^3.1013.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1013.0",
|
||||
"express": "^5.1.0",
|
||||
"mailparser": "^3.7.4",
|
||||
"node-fetch": "^3.3.2"
|
||||
|
||||
3154
_reference/localEmailViewer/public/client-app.js
Normal file
3154
_reference/localEmailViewer/public/client-app.js
Normal file
File diff suppressed because it is too large
Load Diff
45
_reference/localEmailViewer/server/config.js
Normal file
45
_reference/localEmailViewer/server/config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
|
||||
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
export const PORT = Number(process.env.PORT || 3334);
|
||||
export const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses";
|
||||
export const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000);
|
||||
export const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000);
|
||||
export const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566";
|
||||
export const CLOUDWATCH_REGION =
|
||||
process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1";
|
||||
export const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development";
|
||||
export const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000);
|
||||
export const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200);
|
||||
export const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
|
||||
export const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION;
|
||||
export const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
|
||||
export const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION;
|
||||
export const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || "";
|
||||
export const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024);
|
||||
export const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024);
|
||||
|
||||
export const LOCALSTACK_CREDENTIALS = {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test"
|
||||
};
|
||||
|
||||
export const cloudWatchLogsClient = new CloudWatchLogsClient({
|
||||
region: CLOUDWATCH_REGION,
|
||||
endpoint: CLOUDWATCH_ENDPOINT,
|
||||
credentials: LOCALSTACK_CREDENTIALS
|
||||
});
|
||||
|
||||
export const secretsManagerClient = new SecretsManagerClient({
|
||||
region: SECRETS_REGION,
|
||||
endpoint: SECRETS_ENDPOINT,
|
||||
credentials: LOCALSTACK_CREDENTIALS
|
||||
});
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: LOCALSTACK_CREDENTIALS,
|
||||
forcePathStyle: true
|
||||
});
|
||||
845
_reference/localEmailViewer/server/localstack-service.js
Normal file
845
_reference/localEmailViewer/server/localstack-service.js
Normal file
@@ -0,0 +1,845 @@
|
||||
import fetch from "node-fetch";
|
||||
import {
|
||||
DescribeLogGroupsCommand,
|
||||
DescribeLogStreamsCommand,
|
||||
FilterLogEventsCommand
|
||||
} from "@aws-sdk/client-cloudwatch-logs";
|
||||
import { GetSecretValueCommand, ListSecretsCommand } from "@aws-sdk/client-secrets-manager";
|
||||
import { GetObjectCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import { simpleParser } from "mailparser";
|
||||
import {
|
||||
CLOUDWATCH_ENDPOINT,
|
||||
CLOUDWATCH_REGION,
|
||||
FETCH_TIMEOUT_MS,
|
||||
S3_ENDPOINT,
|
||||
S3_IMAGE_PREVIEW_MAX_BYTES,
|
||||
S3_PREVIEW_MAX_BYTES,
|
||||
S3_REGION,
|
||||
SES_ENDPOINT,
|
||||
SECRETS_ENDPOINT,
|
||||
SECRETS_REGION,
|
||||
cloudWatchLogsClient,
|
||||
s3Client,
|
||||
secretsManagerClient
|
||||
} from "./config.js";
|
||||
|
||||
async function loadMessages() {
|
||||
const startedAt = Date.now();
|
||||
const sesMessages = await fetchSesMessages();
|
||||
const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index)));
|
||||
|
||||
messages.sort((left, right) => {
|
||||
if ((right.timestampMs || 0) !== (left.timestampMs || 0)) {
|
||||
return (right.timestampMs || 0) - (left.timestampMs || 0);
|
||||
}
|
||||
|
||||
return right.index - left.index;
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: SES_ENDPOINT,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
totalMessages: messages.length,
|
||||
parseErrors: messages.filter((message) => Boolean(message.parseError)).length,
|
||||
latestMessageTimestamp: messages[0]?.timestamp || "",
|
||||
messages
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSesMessages() {
|
||||
const response = await fetch(SES_ENDPOINT, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SES endpoint responded with ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return Array.isArray(data.messages) ? data.messages : [];
|
||||
}
|
||||
|
||||
async function loadLogGroups() {
|
||||
const groups = [];
|
||||
let nextToken;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
const response = await cloudWatchLogsClient.send(
|
||||
new DescribeLogGroupsCommand({
|
||||
nextToken,
|
||||
limit: 50
|
||||
})
|
||||
);
|
||||
|
||||
groups.push(
|
||||
...(response.logGroups || []).map((group) => ({
|
||||
name: group.logGroupName || "",
|
||||
arn: group.arn || "",
|
||||
storedBytes: group.storedBytes || 0,
|
||||
retentionInDays: group.retentionInDays || 0,
|
||||
creationTime: group.creationTime || 0
|
||||
}))
|
||||
);
|
||||
|
||||
nextToken = response.nextToken;
|
||||
pageCount += 1;
|
||||
} while (nextToken && pageCount < 10);
|
||||
|
||||
return groups.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
async function loadLogStreams(logGroupName) {
|
||||
const streams = [];
|
||||
let nextToken;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
const response = await cloudWatchLogsClient.send(
|
||||
new DescribeLogStreamsCommand({
|
||||
logGroupName,
|
||||
descending: true,
|
||||
orderBy: "LastEventTime",
|
||||
nextToken,
|
||||
limit: 50
|
||||
})
|
||||
);
|
||||
|
||||
streams.push(
|
||||
...(response.logStreams || []).map((stream) => ({
|
||||
name: stream.logStreamName || "",
|
||||
arn: stream.arn || "",
|
||||
lastEventTimestamp: stream.lastEventTimestamp || 0,
|
||||
lastIngestionTime: stream.lastIngestionTime || 0,
|
||||
storedBytes: stream.storedBytes || 0
|
||||
}))
|
||||
);
|
||||
|
||||
nextToken = response.nextToken;
|
||||
pageCount += 1;
|
||||
} while (nextToken && pageCount < 6 && streams.length < 250);
|
||||
|
||||
return streams;
|
||||
}
|
||||
|
||||
async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) {
|
||||
const startedAt = Date.now();
|
||||
const eventMap = new Map();
|
||||
const startTime = Date.now() - windowMs;
|
||||
let nextToken;
|
||||
let previousToken = "";
|
||||
let pageCount = 0;
|
||||
let searchedLogStreams = 0;
|
||||
|
||||
do {
|
||||
const response = await cloudWatchLogsClient.send(
|
||||
new FilterLogEventsCommand({
|
||||
logGroupName,
|
||||
logStreamNames: logStreamName ? [logStreamName] : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
limit,
|
||||
nextToken
|
||||
})
|
||||
);
|
||||
|
||||
for (const event of response.events || []) {
|
||||
const id =
|
||||
event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`;
|
||||
|
||||
if (!eventMap.has(id)) {
|
||||
const message = String(event.message || "").trim();
|
||||
eventMap.set(id, {
|
||||
id,
|
||||
timestamp: event.timestamp || 0,
|
||||
ingestionTime: event.ingestionTime || 0,
|
||||
logStreamName: event.logStreamName || "",
|
||||
message,
|
||||
preview: buildLogPreview(message)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length);
|
||||
previousToken = nextToken || "";
|
||||
nextToken = response.nextToken;
|
||||
pageCount += 1;
|
||||
} while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit);
|
||||
|
||||
const events = [...eventMap.values()]
|
||||
.sort((left, right) => {
|
||||
if ((right.timestamp || 0) !== (left.timestamp || 0)) {
|
||||
return (right.timestamp || 0) - (left.timestamp || 0);
|
||||
}
|
||||
|
||||
return left.logStreamName.localeCompare(right.logStreamName);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
endpoint: CLOUDWATCH_ENDPOINT,
|
||||
region: CLOUDWATCH_REGION,
|
||||
logGroupName,
|
||||
logStreamName,
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
latestTimestamp: events[0]?.timestamp || 0,
|
||||
searchedLogStreams,
|
||||
totalEvents: events.length,
|
||||
events
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSecrets() {
|
||||
const startedAt = Date.now();
|
||||
const secrets = [];
|
||||
let nextToken;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
const response = await secretsManagerClient.send(
|
||||
new ListSecretsCommand({
|
||||
NextToken: nextToken,
|
||||
MaxResults: 50
|
||||
})
|
||||
);
|
||||
|
||||
secrets.push(
|
||||
...(response.SecretList || []).map((secret, index) => ({
|
||||
id: secret.ARN || secret.Name || `secret-${index}`,
|
||||
name: secret.Name || "Unnamed secret",
|
||||
arn: secret.ARN || "",
|
||||
description: secret.Description || "",
|
||||
createdDate: normalizeTimestamp(secret.CreatedDate),
|
||||
lastChangedDate: normalizeTimestamp(secret.LastChangedDate),
|
||||
lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate),
|
||||
deletedDate: normalizeTimestamp(secret.DeletedDate),
|
||||
primaryRegion: secret.PrimaryRegion || "",
|
||||
owningService: secret.OwningService || "",
|
||||
rotationEnabled: Boolean(secret.RotationEnabled),
|
||||
versionCount: Object.keys(secret.SecretVersionsToStages || {}).length,
|
||||
tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0,
|
||||
tags: (secret.Tags || [])
|
||||
.map((tag) => ({
|
||||
key: tag.Key || "",
|
||||
value: tag.Value || ""
|
||||
}))
|
||||
.filter((tag) => tag.key || tag.value)
|
||||
}))
|
||||
);
|
||||
|
||||
nextToken = response.NextToken;
|
||||
pageCount += 1;
|
||||
} while (nextToken && pageCount < 10 && secrets.length < 500);
|
||||
|
||||
secrets.sort((left, right) => {
|
||||
const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0;
|
||||
const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0;
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: SECRETS_ENDPOINT,
|
||||
region: SECRETS_REGION,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
totalSecrets: secrets.length,
|
||||
latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "",
|
||||
secrets
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSecretValue(secretId) {
|
||||
const startedAt = Date.now();
|
||||
const response = await secretsManagerClient.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: secretId
|
||||
})
|
||||
);
|
||||
|
||||
const secretBinary = response.SecretBinary
|
||||
? typeof response.SecretBinary === "string"
|
||||
? response.SecretBinary
|
||||
: Buffer.from(response.SecretBinary).toString("base64")
|
||||
: "";
|
||||
|
||||
return {
|
||||
endpoint: SECRETS_ENDPOINT,
|
||||
region: SECRETS_REGION,
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
id: secretId,
|
||||
name: response.Name || "",
|
||||
arn: response.ARN || "",
|
||||
versionId: response.VersionId || "",
|
||||
versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [],
|
||||
createdDate: normalizeTimestamp(response.CreatedDate),
|
||||
secretString: typeof response.SecretString === "string" ? response.SecretString : "",
|
||||
secretBinary
|
||||
};
|
||||
}
|
||||
|
||||
async function loadS3Buckets() {
|
||||
const startedAt = Date.now();
|
||||
const response = await s3Client.send(new ListBucketsCommand({}));
|
||||
const buckets = (response.Buckets || [])
|
||||
.map((bucket) => ({
|
||||
name: bucket.Name || "",
|
||||
creationDate: normalizeTimestamp(bucket.CreationDate)
|
||||
}))
|
||||
.filter((bucket) => bucket.name)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
endpoint: S3_ENDPOINT,
|
||||
region: S3_REGION,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
totalBuckets: buckets.length,
|
||||
buckets
|
||||
};
|
||||
}
|
||||
|
||||
async function loadS3Objects({ bucket, prefix }) {
|
||||
const startedAt = Date.now();
|
||||
const objects = [];
|
||||
let continuationToken;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
const response = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix || undefined,
|
||||
ContinuationToken: continuationToken,
|
||||
MaxKeys: 200
|
||||
})
|
||||
);
|
||||
|
||||
objects.push(
|
||||
...(response.Contents || []).map((object, index) => ({
|
||||
id: `${bucket}::${object.Key || index}`,
|
||||
bucket,
|
||||
key: object.Key || "",
|
||||
size: object.Size || 0,
|
||||
lastModified: normalizeTimestamp(object.LastModified),
|
||||
etag: String(object.ETag || "").replace(/^"|"$/g, ""),
|
||||
storageClass: object.StorageClass || "STANDARD"
|
||||
}))
|
||||
);
|
||||
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
pageCount += 1;
|
||||
} while (continuationToken && pageCount < 10 && objects.length < 1000);
|
||||
|
||||
objects.sort((left, right) => {
|
||||
const leftTime = Date.parse(left.lastModified || 0) || 0;
|
||||
const rightTime = Date.parse(right.lastModified || 0) || 0;
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return left.key.localeCompare(right.key);
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: S3_ENDPOINT,
|
||||
region: S3_REGION,
|
||||
bucket,
|
||||
prefix,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
totalObjects: objects.length,
|
||||
latestTimestamp: objects[0]?.lastModified || "",
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
async function loadS3ObjectPreview({ bucket, key }) {
|
||||
const startedAt = Date.now();
|
||||
const head = await s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
})
|
||||
);
|
||||
|
||||
const contentType = head.ContentType || guessObjectContentType(key);
|
||||
const contentLength = Number(head.ContentLength || 0);
|
||||
const previewType = resolveS3PreviewType(contentType, key);
|
||||
const result = {
|
||||
endpoint: S3_ENDPOINT,
|
||||
region: S3_REGION,
|
||||
bucket,
|
||||
key,
|
||||
fetchDurationMs: 0,
|
||||
contentType,
|
||||
contentLength,
|
||||
etag: String(head.ETag || "").replace(/^"|"$/g, ""),
|
||||
lastModified: normalizeTimestamp(head.LastModified),
|
||||
metadata: head.Metadata || {},
|
||||
previewType,
|
||||
previewText: "",
|
||||
imageDataUrl: "",
|
||||
truncated: false
|
||||
};
|
||||
|
||||
const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html";
|
||||
const shouldLoadImagePreview =
|
||||
previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES;
|
||||
|
||||
if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) {
|
||||
const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES));
|
||||
const response = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Range: `bytes=0-${previewBytes - 1}`
|
||||
})
|
||||
);
|
||||
const content = Buffer.from(await response.Body.transformToByteArray());
|
||||
result.truncated = contentLength > content.length;
|
||||
|
||||
if (shouldLoadImagePreview) {
|
||||
result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`;
|
||||
} else {
|
||||
result.previewText = content.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
result.fetchDurationMs = Date.now() - startedAt;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadServiceHealthSummary() {
|
||||
const startedAt = Date.now();
|
||||
const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([
|
||||
fetchSesMessages(),
|
||||
loadLogGroups(),
|
||||
loadSecrets(),
|
||||
loadS3Buckets()
|
||||
]);
|
||||
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
fetchDurationMs: Date.now() - startedAt,
|
||||
services: {
|
||||
emails: summarizeHealthResult({
|
||||
icon: "✉️",
|
||||
panel: "emails",
|
||||
label: "SES Emails",
|
||||
result: sesResult,
|
||||
count: sesResult.status === "fulfilled" ? sesResult.value.length : 0,
|
||||
detail: SES_ENDPOINT,
|
||||
noun: "email"
|
||||
}),
|
||||
logs: summarizeHealthResult({
|
||||
icon: "📜",
|
||||
panel: "logs",
|
||||
label: "CloudWatch Logs",
|
||||
result: logsResult,
|
||||
count: logsResult.status === "fulfilled" ? logsResult.value.length : 0,
|
||||
detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`,
|
||||
noun: "group"
|
||||
}),
|
||||
secrets: summarizeHealthResult({
|
||||
icon: "🔐",
|
||||
panel: "secrets",
|
||||
label: "Secrets Manager",
|
||||
result: secretsResult,
|
||||
count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0,
|
||||
detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`,
|
||||
noun: "secret"
|
||||
}),
|
||||
s3: summarizeHealthResult({
|
||||
icon: "🪣",
|
||||
panel: "s3",
|
||||
label: "S3 Explorer",
|
||||
result: s3Result,
|
||||
count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0,
|
||||
detail: `${S3_ENDPOINT} (${S3_REGION})`,
|
||||
noun: "bucket"
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function findSesMessageById(id) {
|
||||
const messages = await fetchSesMessages();
|
||||
return messages.find((message, index) => resolveMessageId(message, index) === id) || null;
|
||||
}
|
||||
|
||||
async function parseSesMessageById(id) {
|
||||
const message = await findSesMessageById(id);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return simpleParser(message.RawData || "");
|
||||
}
|
||||
|
||||
async function toMessageViewModel(message, index) {
|
||||
const id = resolveMessageId(message, index);
|
||||
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData || "");
|
||||
const textContent = normalizeText(parsed.text || "");
|
||||
const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || "");
|
||||
const timestamp = normalizeTimestamp(message.Timestamp || parsed.date);
|
||||
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
from: formatAddressList(parsed.from) || message.Source || "Unknown sender",
|
||||
to: formatAddressList(parsed.to) || "No To Address",
|
||||
replyTo: formatAddressList(parsed.replyTo),
|
||||
subject: parsed.subject || "No Subject",
|
||||
region: message.Region || "",
|
||||
timestamp,
|
||||
timestampMs: timestamp ? Date.parse(timestamp) : 0,
|
||||
messageId: parsed.messageId || "",
|
||||
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
|
||||
attachmentCount: parsed.attachments.length,
|
||||
attachments: parsed.attachments.map((attachment, attachmentIndex) => ({
|
||||
index: attachmentIndex,
|
||||
filename: resolveAttachmentFilename(attachment, attachmentIndex),
|
||||
contentType: attachment.contentType || "application/octet-stream",
|
||||
size: attachment.size || 0
|
||||
})),
|
||||
preview: buildPreview(textContent, renderedHtml),
|
||||
textContent,
|
||||
renderedHtml,
|
||||
hasHtml: Boolean(renderedHtml),
|
||||
parseError: ""
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
from: message.Source || "Unknown sender",
|
||||
to: "Unknown recipient",
|
||||
replyTo: "",
|
||||
subject: "Unable to parse message",
|
||||
region: message.Region || "",
|
||||
timestamp: normalizeTimestamp(message.Timestamp),
|
||||
timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0,
|
||||
messageId: "",
|
||||
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
|
||||
attachmentCount: 0,
|
||||
attachments: [],
|
||||
preview: "This message could not be parsed. Open the raw view to inspect the MIME source.",
|
||||
textContent: "",
|
||||
renderedHtml: "",
|
||||
hasHtml: false,
|
||||
parseError: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMessageId(message, index = 0) {
|
||||
return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`;
|
||||
}
|
||||
|
||||
function resolveAttachmentFilename(attachment, index = 0) {
|
||||
if (attachment?.filename) {
|
||||
return attachment.filename;
|
||||
}
|
||||
|
||||
return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`;
|
||||
}
|
||||
|
||||
function attachmentExtension(contentType) {
|
||||
const normalized = String(contentType || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
return (
|
||||
{
|
||||
"application/json": ".json",
|
||||
"application/pdf": ".pdf",
|
||||
"application/zip": ".zip",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"text/calendar": ".ics",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/plain": ".txt"
|
||||
}[normalized] || ""
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentDisposition(filename) {
|
||||
const fallback = String(filename || "attachment")
|
||||
.replace(/[^\x20-\x7e]/g, "_")
|
||||
.replace(/["\\]/g, "_");
|
||||
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`;
|
||||
}
|
||||
|
||||
function buildInlineDisposition(filename) {
|
||||
const fallback = String(filename || "file")
|
||||
.replace(/[^\x20-\x7e]/g, "_")
|
||||
.replace(/["\\]/g, "_");
|
||||
|
||||
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`;
|
||||
}
|
||||
|
||||
function basenameFromKey(key) {
|
||||
const value = String(key || "");
|
||||
const parts = value.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || "file";
|
||||
}
|
||||
|
||||
function guessObjectContentType(key) {
|
||||
const normalizedKey = String(key || "").toLowerCase();
|
||||
|
||||
if (normalizedKey.endsWith(".json")) {
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".csv")) {
|
||||
return "text/csv";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
|
||||
return "text/html";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) {
|
||||
return "text/plain";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".png")) {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".gif")) {
|
||||
return "image/gif";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".webp")) {
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".svg")) {
|
||||
return "image/svg+xml";
|
||||
}
|
||||
|
||||
if (normalizedKey.endsWith(".pdf")) {
|
||||
return "application/pdf";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function resolveS3PreviewType(contentType, key) {
|
||||
const normalizedType = String(contentType || "").toLowerCase();
|
||||
const normalizedKey = String(key || "").toLowerCase();
|
||||
|
||||
if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
if (normalizedType.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
|
||||
return "html";
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedType.startsWith("text/") ||
|
||||
[".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension))
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "binary";
|
||||
}
|
||||
|
||||
function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) {
|
||||
if (result.status === "fulfilled") {
|
||||
return {
|
||||
ok: true,
|
||||
icon,
|
||||
panel,
|
||||
label,
|
||||
count,
|
||||
summary: `${count} ${noun}${count === 1 ? "" : "s"}`,
|
||||
detail
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
icon,
|
||||
panel,
|
||||
label,
|
||||
count: 0,
|
||||
summary: "Needs attention",
|
||||
detail: result.reason?.message || detail
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildPreview(textContent, renderedHtml) {
|
||||
const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim();
|
||||
|
||||
if (!source) {
|
||||
return "No message preview available.";
|
||||
}
|
||||
|
||||
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
|
||||
}
|
||||
|
||||
function buildLogPreview(message) {
|
||||
const source = String(message || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!source) {
|
||||
return "No log preview available.";
|
||||
}
|
||||
|
||||
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
|
||||
}
|
||||
|
||||
function clampNumber(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(parsed, min), max);
|
||||
}
|
||||
|
||||
function buildRenderedHtml(html) {
|
||||
if (!html) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const value = String(html);
|
||||
const hasDocument = /<html[\s>]/i.test(value) || /<!doctype/i.test(value);
|
||||
|
||||
if (hasDocument) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base target="_blank">
|
||||
<style>body{margin:0;padding:16px;font-family:Arial,sans-serif;background:#fff;}</style>
|
||||
</head>
|
||||
<body>${value}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return String(value || "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ");
|
||||
}
|
||||
|
||||
function formatAddressList(addresses) {
|
||||
if (!addresses?.value?.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return addresses.value
|
||||
.map(({ name, address }) => {
|
||||
if (name && address) {
|
||||
return `${name} <${address}>`;
|
||||
}
|
||||
|
||||
return address || name || "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
async function loadMessageAttachment(messageId, attachmentIndex) {
|
||||
const parsed = await parseSesMessageById(messageId);
|
||||
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = parsed.attachments?.[attachmentIndex];
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filename: resolveAttachmentFilename(attachment, attachmentIndex),
|
||||
contentType: attachment.contentType || "application/octet-stream",
|
||||
content: Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "")
|
||||
};
|
||||
}
|
||||
|
||||
async function loadS3ObjectDownload({ bucket, key }) {
|
||||
const response = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
filename: basenameFromKey(key),
|
||||
contentType: response.ContentType || guessObjectContentType(key),
|
||||
content: Buffer.from(await response.Body.transformToByteArray())
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
buildAttachmentDisposition,
|
||||
buildInlineDisposition,
|
||||
clampNumber,
|
||||
findSesMessageById,
|
||||
loadLogEvents,
|
||||
loadLogGroups,
|
||||
loadLogStreams,
|
||||
loadMessageAttachment,
|
||||
loadMessages,
|
||||
loadS3Buckets,
|
||||
loadS3ObjectDownload,
|
||||
loadS3ObjectPreview,
|
||||
loadS3Objects,
|
||||
loadSecretValue,
|
||||
loadSecrets,
|
||||
loadServiceHealthSummary
|
||||
};
|
||||
495
_reference/localEmailViewer/server/page.js
Normal file
495
_reference/localEmailViewer/server/page.js
Normal file
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
CLOUDWATCH_DEFAULT_GROUP,
|
||||
CLOUDWATCH_DEFAULT_LIMIT,
|
||||
CLOUDWATCH_DEFAULT_WINDOW_MS,
|
||||
CLOUDWATCH_ENDPOINT,
|
||||
CLOUDWATCH_REGION,
|
||||
DEFAULT_REFRESH_MS,
|
||||
S3_DEFAULT_BUCKET,
|
||||
S3_ENDPOINT,
|
||||
S3_REGION,
|
||||
SECRETS_ENDPOINT,
|
||||
SECRETS_REGION,
|
||||
SES_ENDPOINT
|
||||
} from "./config.js";
|
||||
|
||||
function getClientConfig() {
|
||||
return {
|
||||
defaultRefreshMs: DEFAULT_REFRESH_MS,
|
||||
endpoint: SES_ENDPOINT,
|
||||
cloudWatchEndpoint: CLOUDWATCH_ENDPOINT,
|
||||
cloudWatchRegion: CLOUDWATCH_REGION,
|
||||
secretsEndpoint: SECRETS_ENDPOINT,
|
||||
secretsRegion: SECRETS_REGION,
|
||||
s3Endpoint: S3_ENDPOINT,
|
||||
s3Region: S3_REGION,
|
||||
defaultS3Bucket: S3_DEFAULT_BUCKET,
|
||||
defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP,
|
||||
defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS,
|
||||
defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT
|
||||
};
|
||||
}
|
||||
|
||||
function renderHtml() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LocalStack Inspector</title>
|
||||
<style>${renderStyles()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<div class="heroShell">
|
||||
<div class="heroIdentity">
|
||||
<p class="eyebrow">LocalStack Toolbox</p>
|
||||
<h1>Inspector</h1>
|
||||
</div>
|
||||
<div class="heroTopRow">
|
||||
<div class="heroActions">
|
||||
<button id="themeToggle" class="ghost themeToggle" type="button" aria-pressed="false">☀️ Light theme</button>
|
||||
<button id="resetStateButton" class="ghost" type="button">🧹 Reset saved state</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heroStatusRow">
|
||||
<span class="heroStatusLabel">Stack</span>
|
||||
<div id="healthStrip" class="healthStrip" aria-live="polite"></div>
|
||||
<button id="healthRefreshButton" class="mini healthRefreshButton" type="button" title="Refresh service health" aria-label="Refresh service health">🩺</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="emailsPanel" class="workspacePanel">
|
||||
<section class="toolControls">
|
||||
<div class="row">
|
||||
<button id="refreshButton" class="primary" type="button">🔄 Refresh</button>
|
||||
<label class="chip"><input id="autoToggle" type="checkbox" checked> Live refresh</label>
|
||||
<label class="chip">Every
|
||||
<select id="intervalSelect">
|
||||
<option value="5000">5s</option>
|
||||
<option value="10000" selected>10s</option>
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000">30s</option>
|
||||
<option value="60000">60s</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="statusChip" class="status">Waiting for first refresh...</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="searchInput" class="search" type="search" placeholder="Search subject, sender, preview..." autocomplete="off">
|
||||
<button id="clearSearchButton" class="ghost" type="button">Clear</button>
|
||||
<button id="expandAllButton" class="ghost" type="button">Open all</button>
|
||||
<button id="collapseAllButton" class="ghost" type="button">Close all</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<article class="stat"><span>Total</span><strong id="totalStat">0</strong><small id="visibleStat">0 visible</small></article>
|
||||
<article class="stat"><span>New</span><strong id="newStat">0</strong><small>New since last refresh</small></article>
|
||||
<article class="stat"><span>Newest</span><strong id="newestStat" class="small">No messages</strong><small id="updatedStat">Not refreshed yet</small></article>
|
||||
<article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article>
|
||||
</section>
|
||||
|
||||
<div id="emailsContentPane" class="contentPane">
|
||||
<div class="contentStack">
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="empty" class="empty" hidden></div>
|
||||
<section id="list" class="list" aria-live="polite"></section>
|
||||
<div class="paneTopWrap">
|
||||
<button id="scrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="logsPanel" class="workspacePanel" hidden>
|
||||
<section class="toolControls">
|
||||
<div class="row">
|
||||
<button id="logsRefreshButton" class="primary" type="button">🔄 Refresh</button>
|
||||
<label class="chip"><input id="logsAutoToggle" type="checkbox" checked> Live refresh</label>
|
||||
<label class="chip">Every
|
||||
<select id="logsIntervalSelect">
|
||||
<option value="5000">5s</option>
|
||||
<option value="10000" selected>10s</option>
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000">30s</option>
|
||||
<option value="60000">60s</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="logsStatusChip" class="status">Waiting for first refresh...</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="chip">Group
|
||||
<select id="logsGroupSelect"></select>
|
||||
</label>
|
||||
<label class="chip">Stream
|
||||
<select id="logsStreamSelect"></select>
|
||||
</label>
|
||||
<label class="chip">Window
|
||||
<select id="logsWindowSelect">
|
||||
<option value="300000">5m</option>
|
||||
<option value="900000" selected>15m</option>
|
||||
<option value="3600000">1h</option>
|
||||
<option value="21600000">6h</option>
|
||||
<option value="86400000">24h</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="chip">Limit
|
||||
<select id="logsLimitSelect">
|
||||
<option value="100">100</option>
|
||||
<option value="200" selected>200</option>
|
||||
<option value="300">300</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="logsSearchInput" class="search" type="search" placeholder="Search stream name or log content..." autocomplete="off">
|
||||
<button id="logsClearSearchButton" class="ghost" type="button">Clear</button>
|
||||
<label class="chip"><input id="logsWrapToggle" type="checkbox" checked> Wrap lines</label>
|
||||
<label class="chip"><input id="logsTailToggle" type="checkbox"> Tail newest</label>
|
||||
<button id="logsExpandAllButton" class="ghost" type="button">Open all</button>
|
||||
<button id="logsCollapseAllButton" class="ghost" type="button">Close all</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<article class="stat"><span>Events</span><strong id="logsTotalStat">0</strong><small id="logsVisibleStat">0 visible</small></article>
|
||||
<article class="stat"><span>Streams</span><strong id="logsStreamsStat">0</strong><small>Streams in selected group</small></article>
|
||||
<article class="stat"><span>Latest</span><strong id="logsNewestStat" class="small">No events</strong><small id="logsUpdatedStat">Not refreshed yet</small></article>
|
||||
<article class="stat"><span>Fetch</span><strong id="logsFetchStat" class="small">Idle</strong><small id="logsFetchDetail">Endpoint: ${escapeHtml(CLOUDWATCH_ENDPOINT)} (${escapeHtml(CLOUDWATCH_REGION)})</small></article>
|
||||
</section>
|
||||
|
||||
<div id="logsContentPane" class="contentPane">
|
||||
<div class="contentStack">
|
||||
<div id="logsBanner" class="banner" hidden></div>
|
||||
<div id="logsEmpty" class="empty" hidden></div>
|
||||
<section id="logsList" class="logList" aria-live="polite"></section>
|
||||
<div class="paneTopWrap">
|
||||
<button id="logsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="secretsPanel" class="workspacePanel" hidden>
|
||||
<section class="toolControls">
|
||||
<div class="row">
|
||||
<button id="secretsRefreshButton" class="primary" type="button">🔄 Refresh</button>
|
||||
<label class="chip"><input id="secretsAutoToggle" type="checkbox" checked> Live refresh</label>
|
||||
<label class="chip">Every
|
||||
<select id="secretsIntervalSelect">
|
||||
<option value="5000">5s</option>
|
||||
<option value="10000" selected>10s</option>
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000">30s</option>
|
||||
<option value="60000">60s</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="secretsStatusChip" class="status">Waiting for first refresh...</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="secretsSearchInput" class="search" type="search" placeholder="Search secret name, description, service, tags..." autocomplete="off">
|
||||
<button id="secretsClearSearchButton" class="ghost" type="button">Clear</button>
|
||||
<button id="secretsExpandAllButton" class="ghost" type="button">Open all</button>
|
||||
<button id="secretsCollapseAllButton" class="ghost" type="button">Close all</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<article class="stat"><span>Secrets</span><strong id="secretsTotalStat">0</strong><small id="secretsVisibleStat">0 visible</small></article>
|
||||
<article class="stat"><span>Loaded</span><strong id="secretsLoadedStat">0</strong><small>Values loaded this session</small></article>
|
||||
<article class="stat"><span>Latest</span><strong id="secretsNewestStat" class="small">No secrets</strong><small id="secretsUpdatedStat">Not refreshed yet</small></article>
|
||||
<article class="stat"><span>Fetch</span><strong id="secretsFetchStat" class="small">Idle</strong><small id="secretsFetchDetail">Endpoint: ${escapeHtml(SECRETS_ENDPOINT)} (${escapeHtml(SECRETS_REGION)})</small></article>
|
||||
</section>
|
||||
|
||||
<div id="secretsContentPane" class="contentPane">
|
||||
<div class="contentStack">
|
||||
<div id="secretsBanner" class="banner" hidden></div>
|
||||
<div id="secretsEmpty" class="empty" hidden></div>
|
||||
<section id="secretsList" class="list" aria-live="polite"></section>
|
||||
<div class="paneTopWrap">
|
||||
<button id="secretsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="s3Panel" class="workspacePanel" hidden>
|
||||
<section class="toolControls">
|
||||
<div class="row">
|
||||
<button id="s3RefreshButton" class="primary" type="button">🔄 Refresh</button>
|
||||
<label class="chip"><input id="s3AutoToggle" type="checkbox" checked> Live refresh</label>
|
||||
<label class="chip">Every
|
||||
<select id="s3IntervalSelect">
|
||||
<option value="5000">5s</option>
|
||||
<option value="10000" selected>10s</option>
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000">30s</option>
|
||||
<option value="60000">60s</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="s3StatusChip" class="status">Waiting for first refresh...</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="chip">Bucket
|
||||
<select id="s3BucketSelect"></select>
|
||||
</label>
|
||||
<input id="s3PrefixInput" class="search searchCompact" type="search" placeholder="Prefix filter (optional)" autocomplete="off">
|
||||
<button id="s3ApplyPrefixButton" class="ghost" type="button">Apply prefix</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="s3SearchInput" class="search" type="search" placeholder="Search object key, storage class, or etag..." autocomplete="off">
|
||||
<button id="s3ClearSearchButton" class="ghost" type="button">Clear</button>
|
||||
<button id="s3ExpandAllButton" class="ghost" type="button">Open all</button>
|
||||
<button id="s3CollapseAllButton" class="ghost" type="button">Close all</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<article class="stat"><span>Objects</span><strong id="s3TotalStat">0</strong><small id="s3VisibleStat">0 visible</small></article>
|
||||
<article class="stat"><span>Buckets</span><strong id="s3BucketsStat">0</strong><small>Available in LocalStack</small></article>
|
||||
<article class="stat"><span>Latest</span><strong id="s3NewestStat" class="small">No objects</strong><small id="s3UpdatedStat">Not refreshed yet</small></article>
|
||||
<article class="stat"><span>Fetch</span><strong id="s3FetchStat" class="small">Idle</strong><small id="s3FetchDetail">Endpoint: ${escapeHtml(S3_ENDPOINT)} (${escapeHtml(S3_REGION)})</small></article>
|
||||
</section>
|
||||
|
||||
<div id="s3ContentPane" class="contentPane">
|
||||
<div class="contentStack">
|
||||
<div id="s3Banner" class="banner" hidden></div>
|
||||
<div id="s3Empty" class="empty" hidden></div>
|
||||
<section id="s3List" class="list" aria-live="polite"></section>
|
||||
<div class="paneTopWrap">
|
||||
<button id="s3ScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderStyles() {
|
||||
return `
|
||||
:root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;overflow:hidden}
|
||||
body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease}
|
||||
button,input,select,textarea{font:inherit}
|
||||
button{cursor:pointer}
|
||||
.page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden}
|
||||
.hero{display:block;margin-bottom:0}
|
||||
.heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
.card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)}
|
||||
.heroShell,.toolControls{border-radius:18px}
|
||||
.heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px}
|
||||
.toolControls{padding:12px}
|
||||
.heroIdentity{display:grid;gap:3px;min-width:0}
|
||||
.eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
|
||||
h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em}
|
||||
.lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem}
|
||||
.heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
|
||||
.heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
|
||||
.heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
|
||||
.helper{margin:0;color:var(--muted);font-size:.89rem}
|
||||
.healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
|
||||
.healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
|
||||
.healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap}
|
||||
.healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
|
||||
.healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)}
|
||||
.healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)}
|
||||
.healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)}
|
||||
.healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)}
|
||||
.healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)}
|
||||
.healthRefreshButton{flex:0 0 auto;padding:0 10px}
|
||||
.primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
|
||||
.themeToggle{white-space:nowrap}
|
||||
.workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0}
|
||||
.workspacePanel[hidden]{display:none}
|
||||
.toolControls{display:grid;gap:8px}
|
||||
.contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px}
|
||||
.contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px}
|
||||
.paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px}
|
||||
.paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6}
|
||||
.paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto}
|
||||
.paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)}
|
||||
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
||||
.primary,.ghost{min-height:34px;padding:0 12px;font-weight:700}
|
||||
.mini,.tab{min-height:28px;padding:0 10px;font-weight:600}
|
||||
.primary{background:var(--accent);color:#fff7f2}
|
||||
.ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)}
|
||||
.tab{background:transparent;color:var(--muted)}
|
||||
.tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)}
|
||||
.primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)}
|
||||
.chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem}
|
||||
.chip input{margin:0;accent-color:var(--accent)}
|
||||
.chip select{border:none;background:transparent;outline:none;color:var(--ink)}
|
||||
.search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
|
||||
.searchCompact{flex:1 1 220px}
|
||||
.status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600}
|
||||
.status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)}
|
||||
.status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)}
|
||||
.status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)}
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0}
|
||||
.stat{border-radius:16px;padding:10px 12px}
|
||||
.stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em}
|
||||
.stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em}
|
||||
.stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem}
|
||||
.banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
|
||||
.banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)}
|
||||
.list{display:grid;gap:12px;align-content:start}
|
||||
.logList{display:grid;gap:10px;align-content:start;width:100%}
|
||||
.card{overflow:hidden;border-radius:16px}
|
||||
.card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)}
|
||||
.summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))}
|
||||
.summary::-webkit-details-marker{display:none}
|
||||
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.top{justify-content:space-between}
|
||||
.head{min-width:0;flex:1 1 320px}
|
||||
.head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word}
|
||||
.meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word}
|
||||
.time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700}
|
||||
.time{background:rgba(31,41,51,.06)}
|
||||
.tag{background:var(--accent-soft);color:#8d5632}
|
||||
.tag.new{background:rgba(31,143,101,.1);color:var(--ok)}
|
||||
.tag.bad{background:rgba(179,58,58,.1);color:var(--bad)}
|
||||
.preview{margin:0;color:#324150;font-size:.9rem}
|
||||
.body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)}
|
||||
.toolbar{justify-content:space-between;align-items:center}
|
||||
.tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
|
||||
.metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)}
|
||||
.metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.metaCard dd{margin:0;word-break:break-word}
|
||||
.attachments{gap:6px}
|
||||
.attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem}
|
||||
.attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
|
||||
.attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)}
|
||||
.panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff}
|
||||
.logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)}
|
||||
.secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)}
|
||||
.s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)}
|
||||
.logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer}
|
||||
.logSummary::-webkit-details-marker{display:none}
|
||||
.secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))}
|
||||
.s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))}
|
||||
.logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center}
|
||||
.logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
|
||||
.logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)}
|
||||
.secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)}
|
||||
.s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)}
|
||||
.logCopyButton{box-shadow:none}
|
||||
.logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff}
|
||||
.secretValuePanel{display:grid;gap:10px}
|
||||
.secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff}
|
||||
.s3PreviewPanel{display:grid;gap:10px}
|
||||
.s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff}
|
||||
.logBody.wrapOff pre{white-space:pre;word-break:normal}
|
||||
.tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)}
|
||||
.tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)}
|
||||
.tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)}
|
||||
.tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)}
|
||||
.jsonSyntax .jsonKey{color:#b55f2d}
|
||||
.jsonSyntax .jsonString{color:#1f8f65}
|
||||
.jsonSyntax .jsonNumber{color:#2f6ea9}
|
||||
.jsonSyntax .jsonBoolean{color:#9d5f00}
|
||||
.jsonSyntax .jsonNull{color:#b33a3a}
|
||||
iframe{width:100%;min-height:560px;border:none;background:#fff}
|
||||
pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff}
|
||||
.placeholder,.inlineError{padding:12px}
|
||||
.inlineError{color:var(--bad)}
|
||||
body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)}
|
||||
body[data-theme="dark"] .heroShell,
|
||||
body[data-theme="dark"] .toolControls,
|
||||
body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)}
|
||||
body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)}
|
||||
body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
|
||||
body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
|
||||
body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)}
|
||||
body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)}
|
||||
body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)}
|
||||
body[data-theme="dark"] .healthBadge.active .healthBadgeName,
|
||||
body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6}
|
||||
body[data-theme="dark"] .tab{color:#aab8c8}
|
||||
body[data-theme="dark"] .tab.active,
|
||||
body[data-theme="dark"] .ghost,
|
||||
body[data-theme="dark"] .mini,
|
||||
body[data-theme="dark"] .chip,
|
||||
body[data-theme="dark"] .status,
|
||||
body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7}
|
||||
body[data-theme="dark"] .chip select,
|
||||
body[data-theme="dark"] .search::placeholder{color:#9fb0c2}
|
||||
body[data-theme="dark"] .ghost,
|
||||
body[data-theme="dark"] .mini,
|
||||
body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)}
|
||||
body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))}
|
||||
body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)}
|
||||
body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))}
|
||||
body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)}
|
||||
body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))}
|
||||
body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)}
|
||||
body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))}
|
||||
body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)}
|
||||
body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)}
|
||||
body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)}
|
||||
body[data-theme="dark"] .attachmentLink{color:#f6c4a9}
|
||||
body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)}
|
||||
body[data-theme="dark"] .panel,
|
||||
body[data-theme="dark"] pre,
|
||||
body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
|
||||
body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
|
||||
body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
|
||||
body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)}
|
||||
body[data-theme="dark"] .banner,
|
||||
body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)}
|
||||
body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3}
|
||||
body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa}
|
||||
body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff}
|
||||
body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be}
|
||||
body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c}
|
||||
body[data-theme="dark"] .preview,
|
||||
body[data-theme="dark"] .logPreview,
|
||||
body[data-theme="dark"] .metaCard dd,
|
||||
body[data-theme="dark"] .head h2,
|
||||
body[data-theme="dark"] .stat strong,
|
||||
body[data-theme="dark"] h1{color:#edf2f7}
|
||||
body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a}
|
||||
body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0}
|
||||
body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff}
|
||||
body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274}
|
||||
body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c}
|
||||
body[data-theme="dark"] .meta,
|
||||
body[data-theme="dark"] .helper,
|
||||
body[data-theme="dark"] .lede,
|
||||
body[data-theme="dark"] .stat small,
|
||||
body[data-theme="dark"] .stat span,
|
||||
body[data-theme="dark"] .chip,
|
||||
body[data-theme="dark"] .tab{color:#aab8c8}
|
||||
body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7}
|
||||
body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)}
|
||||
@media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}}
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export { getClientConfig, renderHtml };
|
||||
424
_reference/testPlans/commission-based-cut-manual-test-plan.md
Normal file
424
_reference/testPlans/commission-based-cut-manual-test-plan.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Commission-Based Cut Feature Manual Test Plan
|
||||
|
||||
## Purpose
|
||||
Use this guide to manually test the commission-based cut feature from an end-user point of view.
|
||||
|
||||
This plan is written for a non-technical tester. Follow the steps exactly as written and mark each scenario as Pass or Fail.
|
||||
|
||||
## What You Need Before You Start
|
||||
- A login that can open `Manage my Shop`, `Jobs`, and `Time Tickets`.
|
||||
- At least 2 active employees in the shop.
|
||||
- At least 1 converted repair order that already has labor lines on it.
|
||||
- If possible, use a simple test job where the labor sale rates are easy to calculate.
|
||||
- A notebook, spreadsheet, or screenshot folder to record what happened.
|
||||
|
||||
## Recommended Easy-Math Test Data
|
||||
If you can choose your own test job, use something simple like this:
|
||||
|
||||
- Body sale rate: `$100.00`
|
||||
- Refinish sale rate: `$120.00`
|
||||
- Mechanical sale rate: `$80.00`
|
||||
- 1 Body labor line with `10.0` hours
|
||||
- 1 Refinish labor line with `4.0` hours
|
||||
|
||||
This makes the expected payout easy to check:
|
||||
|
||||
- `40%` of `$100.00` = `$40.00`
|
||||
- `30%` of `$120.00` = `$36.00`
|
||||
|
||||
## Important Navigation Notes
|
||||
- Team setup is under `Manage my Shop` > `Employee Teams`.
|
||||
- Team assignment happens on the job line grid in the `Team` column.
|
||||
- Automatic payout happens from the job's `Labor Allocations` card using the `Pay All` button.
|
||||
- If your shop uses task presets, the `Flag Hours` button can preview the payout method before committing tickets.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 1: Create a Simple Commission Team
|
||||
### Goal
|
||||
Confirm a team member can be set to commission and saved successfully.
|
||||
|
||||
### Steps
|
||||
1. Sign in.
|
||||
2. Click `Manage my Shop`.
|
||||
3. Click the `Employee Teams` tab.
|
||||
4. Click `New Team`.
|
||||
5. In `Team Name`, type `Commission Team Test`.
|
||||
6. Make sure `Active` is turned on.
|
||||
7. In `Max Load`, enter `10`.
|
||||
8. Click `New Team Member`.
|
||||
9. In `Employee`, choose an active employee.
|
||||
10. In `Allocation %`, enter `100`.
|
||||
11. In `Payout Method`, choose `Commission %`.
|
||||
12. In each commission field that appears, enter a value.
|
||||
13. For the main labor types you plan to test, use these values:
|
||||
14. Enter `40` for Body.
|
||||
15. Enter `30` for Refinish.
|
||||
16. Enter `25` for Mechanical.
|
||||
17. Enter `20` for Frame.
|
||||
18. Enter `15` for Glass.
|
||||
19. Fill in the remaining commission boxes with any valid number from `0` to `100`.
|
||||
20. Click `Save`.
|
||||
|
||||
### Expected Result
|
||||
- The team saves successfully.
|
||||
- The team stays visible in the Employee Teams list.
|
||||
- The team member card shows a `Commission` tag.
|
||||
- The `Allocation Total` shows `100%`.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 2: Allocation Total Must Equal 100%
|
||||
### Goal
|
||||
Confirm the system blocks a team that does not total exactly 100%.
|
||||
|
||||
### Steps
|
||||
1. Stay on the same team.
|
||||
2. Change `Allocation %` from `100` to `90`.
|
||||
3. Click `Save`.
|
||||
4. Change `Allocation %` from `90` to `110`.
|
||||
5. Click `Save`.
|
||||
6. Change `Allocation %` back to `100`.
|
||||
7. Click `Save` again.
|
||||
|
||||
### Expected Result
|
||||
- When the total is `90%`, the system should not save.
|
||||
- When the total is `110%`, the system should not save.
|
||||
- The page should show that the allocation total is not correct.
|
||||
- When the total is set back to `100%`, the save should succeed.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 3: The Same Employee Cannot Be Added Twice
|
||||
### Goal
|
||||
Confirm the same employee cannot appear twice on one team.
|
||||
|
||||
### Steps
|
||||
1. Open the same team again.
|
||||
2. Click `New Team Member`.
|
||||
3. Choose the same employee already used on the team.
|
||||
4. Enter any valid allocation amount.
|
||||
5. Choose `Commission %`.
|
||||
6. Fill in all required commission fields.
|
||||
7. Click `Save`.
|
||||
|
||||
### Expected Result
|
||||
- The system should block the save.
|
||||
- The team should not save with the same employee listed twice.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 4: Switching Between Hourly and Commission Changes the Input Style
|
||||
### Goal
|
||||
Confirm the rate section changes correctly when the payout method changes.
|
||||
|
||||
### Steps
|
||||
1. Open the same team again.
|
||||
2. On the team member row, change `Payout Method` from `Commission %` to `Hourly`.
|
||||
3. Look at the rate fields that appear.
|
||||
4. Change `Payout Method` back to `Commission %`.
|
||||
5. Look at the rate fields again.
|
||||
|
||||
### Expected Result
|
||||
- In `Hourly` mode, the rate boxes should behave like money/rate fields.
|
||||
- In `Commission %` mode, the rate boxes should behave like percentage fields.
|
||||
- The screen should clearly show you are editing the correct type of value.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 5: Boundary Values for Commission %
|
||||
### Goal
|
||||
Confirm the feature accepts valid boundary values and blocks invalid ones.
|
||||
|
||||
### Steps
|
||||
1. Open the team again.
|
||||
2. In one commission box, enter `0`.
|
||||
3. In another commission box, enter `100`.
|
||||
4. Click `Save`.
|
||||
5. Try to type a value above `100` in one of the commission boxes.
|
||||
6. Try to type a negative value in one of the commission boxes.
|
||||
|
||||
### Expected Result
|
||||
- `0` should be accepted.
|
||||
- `100` should be accepted.
|
||||
- Values above `100` should not be allowed or should fail validation.
|
||||
- Negative values should not be allowed or should fail validation.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 6: Inactive Teams Should Not Be Offered for New Assignment
|
||||
### Goal
|
||||
Confirm inactive teams do not appear as normal team choices.
|
||||
|
||||
### Steps
|
||||
1. Open the team again.
|
||||
2. Turn `Active` off.
|
||||
3. Click `Save`.
|
||||
4. Open a converted repair order.
|
||||
5. Go to the job lines area where the `Team` column is visible.
|
||||
6. Click inside the `Team` field on any labor line.
|
||||
7. Open the team drop-down list.
|
||||
8. Look for `Commission Team Test`.
|
||||
9. Go back to `Manage my Shop` > `Employee Teams`.
|
||||
10. Turn `Active` back on.
|
||||
11. Click `Save`.
|
||||
12. Return to the same job line and open the `Team` drop-down again.
|
||||
|
||||
### Expected Result
|
||||
- When the team is inactive, it should not appear as a normal assignment choice.
|
||||
- After turning it back on, it should appear again.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 7: Assign the Commission Team to a Labor Line
|
||||
### Goal
|
||||
Confirm the team can be assigned to a job line from the job screen.
|
||||
|
||||
### Steps
|
||||
1. Open a converted repair order that has labor lines.
|
||||
2. Find a labor line in the job line grid.
|
||||
3. In the `Team` column, click the blank area or the current team name.
|
||||
4. From the drop-down list, choose `Commission Team Test`.
|
||||
5. Click outside the field so it saves.
|
||||
6. Repeat for at least 1 Body line and 1 Refinish line if both exist.
|
||||
|
||||
### Expected Result
|
||||
- The selected team name should appear in the `Team` column.
|
||||
- The assignment should stay in place after the screen refreshes.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 8: Pay All Creates Commission-Based Tickets
|
||||
### Goal
|
||||
Confirm `Pay All` creates time tickets using the commission rate, not a flat hourly rate.
|
||||
|
||||
### Steps
|
||||
1. Use a converted repair order that has:
|
||||
2. At least 1 labor line assigned to `Commission Team Test`.
|
||||
3. Known labor sale rates on the job.
|
||||
4. No existing time tickets for the same employee and labor type.
|
||||
5. Open that repair order.
|
||||
6. Go to the labor/payroll area where the `Labor Allocations` card is visible.
|
||||
7. Write down the following before you click anything:
|
||||
8. The labor type on the line.
|
||||
9. The sold labor rate for that labor type.
|
||||
10. The hours on that line.
|
||||
11. The commission % you entered for that labor type on the team.
|
||||
12. Click `Pay All`.
|
||||
13. Wait for the success message.
|
||||
14. Look at the `Time Tickets` list on the same screen.
|
||||
15. Find the new ticket created for that employee.
|
||||
|
||||
### Expected Result
|
||||
- The system should show `All hours paid out successfully.`
|
||||
- A new time ticket should appear.
|
||||
- The ticket rate should equal:
|
||||
- `sale rate x commission %`
|
||||
- Example: if Body sale rate is `$100.00` and commission is `40%`, the ticket rate should be `$40.00`.
|
||||
- The productive hours should match the assigned labor hours for that employee.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 9: Different Labor Types Use Different Commission Rates
|
||||
### Goal
|
||||
Confirm the feature uses the correct commission % for each labor type.
|
||||
|
||||
### Steps
|
||||
1. Use a job that has at least:
|
||||
2. One Body labor line.
|
||||
3. One Refinish labor line.
|
||||
4. Make sure both lines are assigned to `Commission Team Test`.
|
||||
5. Confirm your team is set up like this:
|
||||
6. Body = `40%`
|
||||
7. Refinish = `30%`
|
||||
8. Open the job's `Labor Allocations` area.
|
||||
9. Click `Pay All`.
|
||||
10. Review the new time tickets that are created.
|
||||
|
||||
### Expected Result
|
||||
- The Body ticket should use the Body commission %.
|
||||
- The Refinish ticket should use the Refinish commission %.
|
||||
- Example:
|
||||
- If Body sale rate is `$100.00`, Body payout rate should be `$40.00`.
|
||||
- If Refinish sale rate is `$120.00`, Refinish payout rate should be `$36.00`.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 10: Mixed Team With Commission and Hourly Members
|
||||
### Goal
|
||||
Confirm one team can contain both commission and hourly members, and each person is paid correctly.
|
||||
|
||||
### Steps
|
||||
1. Open `Manage my Shop` > `Employee Teams`.
|
||||
2. Open `Commission Team Test`.
|
||||
3. Edit the first team member:
|
||||
4. Keep Employee 1 as `Commission %`.
|
||||
5. Change `Allocation %` to `60`.
|
||||
6. Make sure Body commission is still `40`.
|
||||
7. Add a second team member.
|
||||
8. Choose a different active employee.
|
||||
9. Set `Allocation %` to `40`.
|
||||
10. Set `Payout Method` to `Hourly`.
|
||||
11. Enter an hourly rate for each required labor type.
|
||||
12. For Body, use `$25.00`.
|
||||
13. Fill in the other required hourly boxes with valid values.
|
||||
14. Make sure the total allocation shows `100%`.
|
||||
15. Click `Save`.
|
||||
16. Assign this team to a Body line with `10.0` hours.
|
||||
17. Click `Pay All`.
|
||||
18. Review the new time tickets.
|
||||
|
||||
### Expected Result
|
||||
- Employee 1 should receive `60%` of the hours at the commission-derived rate.
|
||||
- Employee 2 should receive `40%` of the hours at the hourly rate you entered.
|
||||
- Example with a 10-hour Body line and `$100.00` sale rate:
|
||||
- Employee 1 should get `6.0` hours at `$40.00`.
|
||||
- Employee 2 should get `4.0` hours at `$25.00`.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 11: Pay All Only Adds the Remaining Hours
|
||||
### Goal
|
||||
Confirm `Pay All` does not duplicate hours that were already paid.
|
||||
|
||||
### Steps
|
||||
1. Use a job with one Body line assigned to `Commission Team Test`.
|
||||
2. Make sure the line has `10.0` hours.
|
||||
3. In the `Time Tickets` card, click `Enter New Time Ticket`.
|
||||
4. Create a manual time ticket for the same employee and the same labor type.
|
||||
5. Enter `4.0` productive hours.
|
||||
6. Save the manual time ticket.
|
||||
7. Go back to the `Labor Allocations` card.
|
||||
8. Click `Pay All`.
|
||||
9. Review the new ticket that is created.
|
||||
|
||||
### Expected Result
|
||||
- The system should only create the remaining unpaid hours.
|
||||
- In this example, it should add `6.0` hours, not `10.0`.
|
||||
- The payout rate should still use the current commission-based rate.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 12: Unassigned Labor Lines Should Block Automatic Payout
|
||||
### Goal
|
||||
Confirm `Pay All` does not silently pay lines that do not have a team assigned.
|
||||
|
||||
### Steps
|
||||
1. Open a converted repair order with at least 2 labor lines.
|
||||
2. Assign `Commission Team Test` to one line.
|
||||
3. Leave the second labor line with no team assigned.
|
||||
4. Go to the `Labor Allocations` card.
|
||||
5. Click `Pay All`.
|
||||
|
||||
### Expected Result
|
||||
- The system should not quietly pay everything.
|
||||
- You should see an error telling you that not all hours have been assigned.
|
||||
- The unassigned line should still need manual attention.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Scenario 13: Flag Hours Preview Shows the Correct Payout Method
|
||||
### Goal
|
||||
If your shop uses task presets, confirm the preview shows `Commission` for commission-based tickets.
|
||||
|
||||
### Steps
|
||||
1. Open a converted repair order.
|
||||
2. Go to the `Time Tickets` card.
|
||||
3. Click `Flag Hours`.
|
||||
4. Choose a task preset.
|
||||
5. Wait for the preview table to load.
|
||||
6. Review the `Payout Method` column in the preview.
|
||||
7. If the preview includes more than one employee, review each row.
|
||||
|
||||
### Expected Result
|
||||
- The preview table should load without error.
|
||||
- Rows for commission-based employees should show `Commission`.
|
||||
- Rows for hourly employees should show `Hourly`.
|
||||
- If there are unassigned hours, a warning should appear.
|
||||
|
||||
### Record
|
||||
- [ ] Pass
|
||||
- [ ] Fail
|
||||
- Notes:
|
||||
|
||||
---
|
||||
|
||||
## Quick Regression Checklist
|
||||
- [ ] I can create a commission-based team.
|
||||
- [ ] Allocation must total exactly 100%.
|
||||
- [ ] The same employee cannot be added twice to one team.
|
||||
- [ ] Inactive teams do not appear for normal assignment.
|
||||
- [ ] A team can be assigned to job lines from the `Team` column.
|
||||
- [ ] `Pay All` creates commission-based tickets correctly.
|
||||
- [ ] Different labor types use different commission percentages.
|
||||
- [ ] Mixed commission and hourly teams calculate correctly.
|
||||
- [ ] `Pay All` only creates the remaining unpaid hours.
|
||||
- [ ] Unassigned labor lines stop automatic payout.
|
||||
- [ ] `Flag Hours` preview shows the correct payout method.
|
||||
|
||||
## Tester Sign-Off
|
||||
- Tester name:
|
||||
- Test date:
|
||||
- Environment:
|
||||
- Overall result:
|
||||
- Follow-up issues found:
|
||||
647
_reference/testPlans/select-component-test-plan.md
Normal file
647
_reference/testPlans/select-component-test-plan.md
Normal file
@@ -0,0 +1,647 @@
|
||||
# Ant Design Select.Option Deprecation - Manual Testing Plan
|
||||
**Branch:** `feature/IO-3544-Ant-Select-Deprecation`
|
||||
**Base Branch:** `master-AIO`
|
||||
**Jira:** IO-3544
|
||||
|
||||
## Overview
|
||||
This branch migrates all Ant Design `<Select.Option>` components to the new `options` prop pattern (required for Ant Design v5+). The deprecated `Select.Option` child component pattern has been replaced with the `options` array prop.
|
||||
|
||||
## What Changed
|
||||
- **Old Pattern:** `<Select><Select.Option value="x">Label</Select.Option></Select>`
|
||||
- **New Pattern:** `<Select options={[{ value: "x", label: "Label" }]} />`
|
||||
- Search filtering updated from `optionFilterProp: "children"` to `optionFilterProp: "label"` or custom props
|
||||
- Custom filter functions updated to use `option.label` instead of `option.props.children`
|
||||
- Complex components with custom rendered content (tags, icons, styled elements) now use `label` prop with JSX
|
||||
|
||||
## Code Review Findings ✅
|
||||
**Validation completed on representative samples:**
|
||||
- ✅ Search functionality properly migrated (showSearch object syntax correct)
|
||||
- ✅ Custom rendered content (tags, badges, icons) preserved in label prop
|
||||
- ✅ Labor type selectors correctly implement all 14 types
|
||||
- ✅ Vendor search with favorites and discount tags working correctly
|
||||
- ✅ Employee selectors with flat_rate/straight_time tags properly structured
|
||||
- ✅ Job search with owner display and status tags correctly migrated
|
||||
- ✅ CiecaSelect utility updated to return options array format
|
||||
- ✅ Performance optimizations added (useMemo in jobs-convert-button)
|
||||
- ✅ Custom optionFilterProp values used where needed (e.g., "name", "search")
|
||||
|
||||
## Testing Strategy
|
||||
For each select component below, verify:
|
||||
1. ✅ **Options Display:** All options appear correctly
|
||||
2. ✅ **Selection:** Can select an option and value is saved
|
||||
3. ✅ **Search/Filter:** Search functionality works (if applicable)
|
||||
4. ✅ **Visual Rendering:** Labels, tags, and custom content display properly (especially vendor discounts, employee tags, job status badges)
|
||||
5. ✅ **Form Integration:** Value persists and submits correctly
|
||||
6. ✅ **Custom Search Props:** Components using custom optionFilterProp (name, search) work correctly
|
||||
|
||||
---
|
||||
|
||||
## Component Test Cases
|
||||
|
||||
### 1. Employee Assignment & Allocation
|
||||
**Files Modified:**
|
||||
- `allocations-assignment.component.jsx`
|
||||
- `allocations-bulk-assignment.component.jsx`
|
||||
- `labor-allocations-adjustment-edit.component.jsx`
|
||||
- `employee-search-select.component.jsx`
|
||||
- `employee-search-select-email.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Job Line Allocation:** Assign employee to job line
|
||||
- Navigate to a job → Job Lines tab
|
||||
- Click allocate hours to an employee
|
||||
- Verify employee dropdown shows full names
|
||||
- Search for employee by name
|
||||
- Verify selection saves
|
||||
|
||||
- [ ] **Bulk Assignment:** Assign multiple job lines to employee
|
||||
- Select multiple job lines
|
||||
- Open bulk assignment modal
|
||||
- Verify employee selector works
|
||||
- Verify employee number, name, and tags (flat rate/straight time) display
|
||||
|
||||
- [ ] **Labor Allocations Adjustment:** Edit labor allocations
|
||||
- Navigate to labor allocations
|
||||
- Edit adjustment form
|
||||
- Verify employee dropdowns work with search
|
||||
|
||||
- [ ] **Employee Search Select with Email:**
|
||||
- Test in any form using employee email selector (uses custom `optionFilterProp: "search"`)
|
||||
- Search by employee number, first name, or last name
|
||||
- Verify employee number, name display correctly
|
||||
- **Critical:** Verify green tag shows "Flat Rate" or "Straight Time"
|
||||
- Verify blue email tag displays when showEmail=true
|
||||
- Test that search matches against concatenated string
|
||||
|
||||
---
|
||||
|
||||
### 2. Job Management
|
||||
**Files Modified:**
|
||||
- `job-search-select.component.jsx`
|
||||
- `jobs-create-jobs-info.component.jsx`
|
||||
- `jobs-detail-general.component.jsx`
|
||||
- `jobs-detail-header-actions.component.jsx`
|
||||
- `jobs-convert-button.component.jsx`
|
||||
- `jobs-close-lines.component.jsx`
|
||||
- `job-3rd-party-modal.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Job Search Select:** Search and select jobs
|
||||
- Any form with job selector (e.g., linking jobs, referencing jobs)
|
||||
- Search by RO number, customer name, or vehicle (uses `filterOption: false` with custom search)
|
||||
- **Critical:** Verify job label shows: `[CLM_NO |] RO_NUMBER | Owner Name | Year Make Model`
|
||||
- **Critical:** Verify status tag displays (e.g., "OPEN", "CLOSED")
|
||||
- Verify loading spinner appears during search
|
||||
- Test with claim numbers visible (clm_no prop)
|
||||
- Verify selection works
|
||||
|
||||
- [ ] **Create Job Form:**
|
||||
- Navigate to Create New Job
|
||||
- Test all dropdowns in job info section:
|
||||
- Job type selection
|
||||
- Status selection
|
||||
- Department/class selection
|
||||
- Estimator selection
|
||||
- File handler selection
|
||||
- Verify options display and selection works
|
||||
|
||||
- [ ] **Job Detail General Tab:**
|
||||
- Open any job → General tab
|
||||
- Test select dropdowns:
|
||||
- Status select
|
||||
- Department/class select
|
||||
- Estimator select
|
||||
- File handler select
|
||||
- Responsibility center select
|
||||
- Verify all dropdowns work with search
|
||||
|
||||
- [ ] **Job Convert Button:**
|
||||
- Open estimate job
|
||||
- Click convert to RO
|
||||
- Verify conversion type dropdown works
|
||||
- Test all options in conversion modal
|
||||
|
||||
- [ ] **Close Job Lines:**
|
||||
- Open converted job
|
||||
- Go to close/finalize
|
||||
- Test location selector in close lines modal
|
||||
- Verify cost center dropdowns
|
||||
|
||||
- [ ] **Third Party Modal:**
|
||||
- Open job → Third party integration
|
||||
- Test company/payer selector
|
||||
- Verify dropdown options and selection
|
||||
|
||||
---
|
||||
|
||||
### 3. Job Lines & Labor
|
||||
**Files Modified:**
|
||||
- `job-lines-upsert-modal.component.jsx`
|
||||
- `job-line-bulk-assign.component.jsx`
|
||||
- `job-line-convert-to-labor.component.jsx`
|
||||
- `job-line-dispatch-button.component.jsx`
|
||||
- `job-line-status-popup.component.jsx`
|
||||
- `job-line-team-assignment.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Add/Edit Job Line:**
|
||||
- Open job → Add new line
|
||||
- Test all dropdowns:
|
||||
- **Labor type selector** (LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1-LA4)
|
||||
- Location selector
|
||||
- Status selector
|
||||
- Skill/category selector
|
||||
- Verify 14 labor type options display correctly
|
||||
- Test search functionality
|
||||
|
||||
- [ ] **Bulk Line Assignment:**
|
||||
- Select multiple job lines
|
||||
- Open bulk assign
|
||||
- Test team assignment dropdown
|
||||
|
||||
- [ ] **Convert to Labor:**
|
||||
- Select part line
|
||||
- Convert to labor
|
||||
- Test labor type dropdown (LAA, LAB, etc.)
|
||||
- Verify all 14 types available
|
||||
|
||||
- [ ] **Line Dispatch:**
|
||||
- Open dispatch modal for job line
|
||||
- Test team/employee selector
|
||||
|
||||
- [ ] **Line Status Popup:**
|
||||
- Change job line status
|
||||
- Verify status dropdown options
|
||||
|
||||
- [ ] **Team Assignment:**
|
||||
- Assign team to job line
|
||||
- Test team selector dropdown
|
||||
|
||||
---
|
||||
|
||||
### 4. Owners & Vehicles
|
||||
**Files Modified:**
|
||||
- `owner-search-select.component.jsx`
|
||||
- `vehicle-search-select.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Owner Search Select:**
|
||||
- Any form with owner selector
|
||||
- Search by owner name
|
||||
- Verify owner options display
|
||||
- Test selection and search
|
||||
|
||||
- [ ] **Vehicle Search Select:**
|
||||
- Any form with vehicle selector
|
||||
- Search by VIN, license plate, or vehicle description
|
||||
- Verify vehicle options display correctly
|
||||
- Test selection works
|
||||
|
||||
---
|
||||
|
||||
### 5. Vendors & Parts
|
||||
**Files Modified:**
|
||||
- `vendor-search-select.component.jsx`
|
||||
- `parts-order-modal.component.jsx`
|
||||
- `parts-receive-modal.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Vendor Search Select:**
|
||||
- Navigate to bills or parts ordering
|
||||
- Test vendor selector
|
||||
- Search for vendor by name (uses custom `optionFilterProp: "name"`)
|
||||
- **Critical:** Verify favorites (with heart icon) display at top
|
||||
- **Critical:** Verify discount tags show correctly (e.g., "10%")
|
||||
- **Critical:** Verify vendor tags display
|
||||
- Verify phone numbers display if showPhone enabled
|
||||
- Test selection saves discount value to form
|
||||
|
||||
- [ ] **Parts Order Modal:**
|
||||
- Order parts for a job
|
||||
- Test all dropdowns in order form:
|
||||
- Vendor selector
|
||||
- Status selector
|
||||
- Priority selector
|
||||
- Verify options and selection
|
||||
|
||||
- [ ] **Parts Receive Modal:**
|
||||
- Receive parts
|
||||
- Test selectors in receive form
|
||||
- Verify dropdown functionality
|
||||
|
||||
---
|
||||
|
||||
### 6. Bills & Payments
|
||||
**Files Modified:**
|
||||
- `bill-form.component.jsx`
|
||||
- `bill-form-lines.component.jsx`
|
||||
- `bill-form-lines-extended.formitem.component.jsx`
|
||||
- `payment-form.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Bill Entry Form:**
|
||||
- Navigate to Bills → Add New Bill
|
||||
- Test all dropdowns:
|
||||
- Vendor selector
|
||||
- Payment terms selector
|
||||
- GL account selector
|
||||
- Tax code selector
|
||||
- Verify options display
|
||||
|
||||
- [ ] **Bill Lines:**
|
||||
- Add bill line
|
||||
- Test line-level selectors:
|
||||
- Job selector
|
||||
- Job line selector
|
||||
- Account selector
|
||||
- Location selector
|
||||
|
||||
- [ ] **Bill Lines Extended:**
|
||||
- Add extended bill line
|
||||
- Test responsibility center dropdown
|
||||
- Test cost center dropdown
|
||||
|
||||
- [ ] **Payment Form:**
|
||||
- Navigate to Payments → New Payment
|
||||
- Test all dropdowns:
|
||||
- Vendor selector
|
||||
- Payment method selector
|
||||
- Bank account selector
|
||||
- Verify selection works
|
||||
|
||||
---
|
||||
|
||||
### 7. Shop Configuration
|
||||
**Files Modified:**
|
||||
- `shop-info.general.component.jsx`
|
||||
- `shop-info.intake.component.jsx`
|
||||
- `shop-info.responsibilitycenters.component.jsx`
|
||||
- `shop-info.rostatus.component.jsx`
|
||||
- `shop-info.speedprint.component.jsx`
|
||||
- `shop-intellipay-config.component.jsx`
|
||||
- `shop-employees-form.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Shop Info - General:**
|
||||
- Navigate to Shop Settings → General
|
||||
- Test all dropdowns:
|
||||
- Timezone selector
|
||||
- Currency selector
|
||||
- Date format selector
|
||||
- Default options
|
||||
|
||||
- [ ] **Shop Info - Intake:**
|
||||
- Navigate to Shop Settings → Intake
|
||||
- Test intake form selectors
|
||||
- Verify default options work
|
||||
|
||||
- [ ] **Shop Info - Responsibility Centers:**
|
||||
- Navigate to Shop Settings → Responsibility Centers
|
||||
- Test cost center dropdowns
|
||||
- Test location selectors
|
||||
- **Note:** This file had major changes (980 lines modified)
|
||||
|
||||
- [ ] **Shop Info - RO Status:**
|
||||
- Navigate to Shop Settings → RO Status
|
||||
- Test status configuration dropdowns
|
||||
- **Note:** 120 lines modified
|
||||
|
||||
- [ ] **Shop Info - Speed Print:**
|
||||
- Navigate to Shop Settings → Speed Print
|
||||
- Test printer selector
|
||||
- Test template selector
|
||||
|
||||
- [ ] **IntelliPay Config:**
|
||||
- Navigate to Shop Settings → IntelliPay
|
||||
- Test configuration dropdowns (56 lines modified)
|
||||
|
||||
- [ ] **Shop Employees Form:**
|
||||
- Navigate to Shop Settings → Employees → Add/Edit
|
||||
- Test all dropdowns:
|
||||
- Role selector
|
||||
- Department selector
|
||||
- Pay type selector
|
||||
- Verify options display
|
||||
|
||||
---
|
||||
|
||||
### 8. Schedule & Time Tracking
|
||||
**Files Modified:**
|
||||
- `schedule-job-modal.component.jsx`
|
||||
- `schedule-manual-event.component.jsx`
|
||||
- `tech-job-clock-in-form.component.jsx`
|
||||
- `tech-job-clock-out-button.component.jsx`
|
||||
- `time-ticket-modal.component.jsx`
|
||||
- `time-ticket-shift-form.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Schedule Job Modal:**
|
||||
- Navigate to Schedule → Add Appointment
|
||||
- Test all dropdowns:
|
||||
- Job selector
|
||||
- Employee selector
|
||||
- Time slot selector
|
||||
- Duration selector
|
||||
|
||||
- [ ] **Schedule Manual Event:**
|
||||
- Add manual event to schedule
|
||||
- Test event type dropdown
|
||||
- Test employee selector
|
||||
|
||||
- [ ] **Tech Clock In Form:**
|
||||
- Navigate to Tech Portal
|
||||
- Clock in to job
|
||||
- Test job selector
|
||||
- Test operation selector
|
||||
|
||||
- [ ] **Tech Clock Out:**
|
||||
- Clock out from job
|
||||
- Test reason selector (if applicable)
|
||||
- Verify dropdown works
|
||||
|
||||
- [ ] **Time Ticket Modal:**
|
||||
- Enter/edit time ticket
|
||||
- Test all dropdowns:
|
||||
- Employee selector
|
||||
- Job selector
|
||||
- Operation selector
|
||||
|
||||
- [ ] **Time Ticket Shift Form:**
|
||||
- Manage shift
|
||||
- Test shift type selector
|
||||
- Test employee selector
|
||||
|
||||
---
|
||||
|
||||
### 9. Contracts & Courtesy Cars
|
||||
**Files Modified:**
|
||||
- `contract-convert-to-ro.component.jsx`
|
||||
- `contract-status-select.component.jsx`
|
||||
- `courtesy-car-readiness-select.component.jsx`
|
||||
- `courtesy-car-status-select.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Contract Convert to RO:**
|
||||
- Open contract
|
||||
- Convert to RO
|
||||
- Test conversion options dropdown
|
||||
|
||||
- [ ] **Contract Status Select:**
|
||||
- Change contract status
|
||||
- Test status options:
|
||||
- New
|
||||
- Out
|
||||
- Returned
|
||||
- Verify all 3 status options work
|
||||
|
||||
- [ ] **Courtesy Car Readiness:**
|
||||
- Navigate to Courtesy Cars
|
||||
- Change car readiness
|
||||
- Test readiness options:
|
||||
- Ready
|
||||
- Not Ready
|
||||
- Verify both options work
|
||||
|
||||
- [ ] **Courtesy Car Status:**
|
||||
- Change courtesy car status
|
||||
- Test all status options:
|
||||
- In
|
||||
- In Service
|
||||
- Out
|
||||
- Sold
|
||||
- Lease Return
|
||||
- Unavailable
|
||||
- Verify all 6 status options work
|
||||
|
||||
---
|
||||
|
||||
### 10. Email & Communication
|
||||
**Files Modified:**
|
||||
- `email-overlay.component.jsx`
|
||||
- `chat-tag-ro.component.jsx`
|
||||
- `parts-shop-info-email-presets.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Email Overlay:**
|
||||
- Send email from any feature
|
||||
- Test all dropdowns:
|
||||
- From email selector (current user, shop email, custom emails)
|
||||
- Template selector
|
||||
- Priority selector
|
||||
- Verify custom from emails display correctly
|
||||
|
||||
- [ ] **Chat Tag RO:**
|
||||
- Open chat
|
||||
- Tag to RO
|
||||
- Test RO selector dropdown
|
||||
|
||||
- [ ] **Parts Shop Email Presets:**
|
||||
- Navigate to Parts Settings → Email Presets
|
||||
- Test preset selector
|
||||
- Verify options display
|
||||
|
||||
---
|
||||
|
||||
### 11. DMS Integration
|
||||
**Files Modified:**
|
||||
- `dms-post-form/cdklike-dms-post-form.jsx`
|
||||
- `dms/dms.container.jsx`
|
||||
- `dms-payables/dms-payables.container.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **DMS Post Form:**
|
||||
- Navigate to DMS posting
|
||||
- Test all dropdowns in post form:
|
||||
- Account selector
|
||||
- Department selector
|
||||
- GL code selector
|
||||
|
||||
- [ ] **DMS Container:**
|
||||
- Navigate to DMS section
|
||||
- Test filter dropdowns
|
||||
- Verify selection works
|
||||
|
||||
- [ ] **DMS Payables:**
|
||||
- Navigate to DMS Payables
|
||||
- Test payables filter selectors
|
||||
|
||||
---
|
||||
|
||||
### 12. Production & Admin
|
||||
**Files Modified:**
|
||||
- `production-list-config-manager.component.jsx`
|
||||
- `jobs-admin-class.component.jsx`
|
||||
- `jobs-close/jobs-close.component.jsx`
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **Production List Config:**
|
||||
- Navigate to Production Board → Configure
|
||||
- Test column configuration dropdown
|
||||
- Verify display settings work
|
||||
|
||||
- [ ] **Jobs Admin Class:**
|
||||
- Navigate to Admin → Jobs
|
||||
- Change job class/department
|
||||
- Test class selector dropdown
|
||||
|
||||
- [ ] **Jobs Close Page:**
|
||||
- Navigate to Jobs → Close/Export
|
||||
- Test filter dropdowns:
|
||||
- Status filter
|
||||
- Date range
|
||||
- Department filter
|
||||
- Verify selections work
|
||||
|
||||
---
|
||||
|
||||
### 13. Miscellaneous Components
|
||||
**Files Modified:**
|
||||
- `Ciecaselect.jsx` (utility component - 75 lines modified)
|
||||
|
||||
**Test Scenarios:**
|
||||
- [ ] **CIECA Select Utility:**
|
||||
- Used in bill-form-lines-extended for labor type adjustments
|
||||
- Returns options array with 14 labor types (LAA-LAU, LA1-LA4)
|
||||
- Returns 10 part types (PAA, PAC, PAL, PAG, PAM, PAP, PAN, PAO, PAR, PAS) when parts=true
|
||||
- Verify function returns properly formatted options array
|
||||
- Test in any form using DMS integration with CIECA codes
|
||||
|
||||
---
|
||||
|
||||
## Cross-Component Testing
|
||||
|
||||
### Search Functionality
|
||||
Test search across all searchable selects:
|
||||
- [ ] Employee search (by name, employee number)
|
||||
- [ ] Job search (by RO number, customer name, vehicle)
|
||||
- [ ] Vendor search (by name)
|
||||
- [ ] Vehicle search (by VIN, plate, make/model)
|
||||
- [ ] Owner search (by name)
|
||||
|
||||
### Multi-Select Components
|
||||
If any components use `mode="multiple"`:
|
||||
- [ ] Verify multi-select works
|
||||
- [ ] Verify tags display correctly
|
||||
- [ ] Verify removal of selections works
|
||||
|
||||
### Disabled State
|
||||
- [ ] Test dropdowns in disabled state
|
||||
- [ ] Verify disabled styling matches original
|
||||
|
||||
### Form Validation
|
||||
- [ ] Test required field validation on selects
|
||||
- [ ] Verify error messages display correctly
|
||||
- [ ] Test form submission with select values
|
||||
|
||||
---
|
||||
|
||||
## Regression Testing Priority
|
||||
|
||||
### High Priority (Critical User Flows)
|
||||
1. ✅ Create new job with all required fields
|
||||
2. ✅ Add job lines with labor types
|
||||
3. ✅ Assign employees to job lines
|
||||
4. ✅ Order parts with vendor selection
|
||||
5. ✅ Enter bills with vendor and account selection
|
||||
6. ✅ Close and export jobs
|
||||
|
||||
### Medium Priority
|
||||
7. Convert estimate to RO
|
||||
8. Schedule appointments
|
||||
9. Clock in/out (tech portal)
|
||||
10. Update shop configuration
|
||||
11. Manage courtesy cars
|
||||
|
||||
### Low Priority (Admin Functions)
|
||||
12. DMS integration posting
|
||||
13. Production board configuration
|
||||
14. Admin job modifications
|
||||
|
||||
---
|
||||
|
||||
## Browser Testing
|
||||
Test in:
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (if applicable)
|
||||
- [ ] Edge (latest)
|
||||
|
||||
---
|
||||
|
||||
## Known Changed Components Summary
|
||||
**Total Files Modified:** 54 client files + 1 server file
|
||||
|
||||
**Labor Type Selectors (14 options):**
|
||||
- LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1, LA2, LA3, LA4
|
||||
- Found in: job-lines-upsert-modal, job-line-convert-to-labor, bill-form-lines
|
||||
|
||||
**Most Complex Changes:**
|
||||
- `shop-info.responsibilitycenters.component.jsx` (980 lines changed)
|
||||
- `vendor-search-select.component.jsx` (120 lines changed)
|
||||
- `shop-info.rostatus.component.jsx` (120 lines changed)
|
||||
- `jobs-convert-button.component.jsx` (198 lines changed)
|
||||
- `Ciecaselect.jsx` (75 lines changed)
|
||||
|
||||
---
|
||||
|
||||
## Internal Code Review Results ✅
|
||||
|
||||
**Files Validated (Sample):**
|
||||
1. ✅ `allocations-assignment.component.jsx` - Simple employee selector with search
|
||||
2. ✅ `contract-status-select.component.jsx` - Static 3-option select
|
||||
3. ✅ `courtesy-car-status-select.component.jsx` - Static 6-option select
|
||||
4. ✅ `job-lines-upsert-modal.component.jsx` - 14 labor type options inline
|
||||
5. ✅ `email-overlay.component.jsx` - From email with custom emails array
|
||||
6. ✅ `employee-search-select-email.component.jsx` - Complex with tags and custom search prop
|
||||
7. ✅ `bill-form-lines-extended.formitem.component.jsx` - CiecaSelect utility usage
|
||||
8. ✅ `vendor-search-select.component.jsx` - Complex with favorites, tags, discount, phone
|
||||
9. ✅ `job-search-select.component.jsx` - Complex with owner display, status tags, loading states
|
||||
10. ✅ `Ciecaselect.jsx` - Utility function returning options array
|
||||
|
||||
**Validation Checklist:**
|
||||
- [x] All `Select.Option` patterns removed
|
||||
- [x] Replaced with `options` array prop
|
||||
- [x] `showSearch` uses object syntax `{{ optionFilterProp: "label" }}`
|
||||
- [x] Custom `optionFilterProp` used where needed ("name", "search", etc.)
|
||||
- [x] Complex rendered content preserved in `label` prop with JSX
|
||||
- [x] Tags, icons, badges, and styled elements working correctly
|
||||
- [x] Search functionality using correct property references
|
||||
- [x] Labor types: All 14 types present (LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1, LA2, LA3, LA4)
|
||||
- [x] Part types: All 10 types in CiecaSelect (PAA, PAC, PAL, PAG, PAM, PAP, PAN, PAO, PAR, PAS)
|
||||
- [x] Custom filterOption functions updated (option.label vs option.props.children)
|
||||
- [x] Performance optimizations added (useMemo for large option lists)
|
||||
- [x] labelRender custom rendering preserved where used
|
||||
- [x] optionLabelProp used correctly for display vs value
|
||||
|
||||
**Known Complex Patterns Verified:**
|
||||
1. **Vendor Select:** Favorites with heart icon, discount tags, phone display, custom search by "name"
|
||||
2. **Employee Select:** Flat rate/straight time tags, custom search by "search" prop (employee number + name)
|
||||
3. **Job Select:** Owner display function, status tags, loading states, conditional claim number display
|
||||
4. **Email Overlay:** Multiple from addresses (user email, shop email, custom md_from_emails array)
|
||||
5. **Bill Lines Extended:** Conditional DMS vs responsibility centers, CiecaSelect utility
|
||||
|
||||
**No Issues Found** - All migrations follow the correct pattern.
|
||||
|
||||
---
|
||||
|
||||
## Testing Notes
|
||||
- Focus on components with search functionality - filtering logic changed from `children` to `label`
|
||||
- Pay attention to components with custom rendered content (tags, badges, formatted text)
|
||||
- Verify `optionFilterProp` works correctly for custom search fields
|
||||
- Test components that map over arrays to generate options
|
||||
- Check components with conditional option rendering
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
- [ ] All high priority tests passed
|
||||
- [ ] All medium priority tests passed
|
||||
- [ ] All low priority tests passed
|
||||
- [ ] No console errors observed
|
||||
- [ ] Visual appearance matches original
|
||||
- [ ] Performance is acceptable (no lag in large dropdowns)
|
||||
|
||||
**Tested By:** _________________
|
||||
**Date:** _________________
|
||||
**Environment:** _________________
|
||||
**Notes:** _________________
|
||||
@@ -2696,6 +2696,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>oem_partno</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>quantity</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -3684,6 +3705,48 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>feedback_placeholder</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>feedback_prompt</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>generic_failure</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -3831,6 +3894,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>submit_feedback</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
@@ -8641,6 +8725,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>manual-line</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>partsqueue</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -17816,6 +17921,468 @@
|
||||
<folder_node>
|
||||
<name>labels</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>banner_message</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>banner_status_connected</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>banner_status_disconnected</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>clear_logs</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>collapse_all</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>color_json</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>copied</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>copy</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>copy_request</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>copy_response</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>details</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>expand_all</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>hide_details</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>log_level</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>plain_json</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>provider_cdk</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>provider_dms</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>provider_fortellis</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>provider_pbs</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>provider_reynolds</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>reconnect</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>reconnected_export_service</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>refreshallocations</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -17837,6 +18404,153 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>request_xml</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>response_xml</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_validation_message</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_validation_notice_description</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_validation_notice_title</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>transport_ws</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>transport_wss</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
</children>
|
||||
@@ -20590,6 +21304,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>done</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>download</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -23009,6 +23744,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>validationerror</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>view</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -23423,6 +24179,27 @@
|
||||
<folder_node>
|
||||
<name>validation</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>array</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>dateRangeExceeded</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -57452,6 +58229,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>work_in_progress_labour_summary</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>work_in_progress_payables</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -57473,6 +58271,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>work_in_progress_payables_summary</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
</children>
|
||||
|
||||
1011
client/package-lock.json
generated
1011
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.35.3",
|
||||
"@amplitude/analytics-browser": "^2.37.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -16,45 +16,45 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
"@firebase/app": "^0.14.8",
|
||||
"@firebase/auth": "^1.12.0",
|
||||
"@firebase/firestore": "^4.11.0",
|
||||
"@firebase/messaging": "^0.12.22",
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@firebase/analytics": "^0.10.21",
|
||||
"@firebase/app": "^0.14.10",
|
||||
"@firebase/auth": "^1.12.2",
|
||||
"@firebase/firestore": "^4.13.0",
|
||||
"@firebase/messaging": "^0.12.25",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@sentry/cli": "^3.2.2",
|
||||
"@sentry/react": "^10.40.0",
|
||||
"@sentry/cli": "^3.3.3",
|
||||
"@sentry/react": "^10.45.0",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.1",
|
||||
"antd": "^6.3.3",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.13.6",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs-business-days2": "^1.3.2",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"env-cmd": "^11.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.13.0",
|
||||
"graphql": "^16.13.1",
|
||||
"graphql-ws": "^6.0.7",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next": "^25.10.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.38",
|
||||
"lightningcss": "^1.31.1",
|
||||
"logrocket": "^12.0.0",
|
||||
"libphonenumber-js": "^1.12.40",
|
||||
"lightningcss": "^1.32.0",
|
||||
"logrocket": "^12.1.0",
|
||||
"markerjs2": "^2.32.7",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.71",
|
||||
"posthog-js": "^1.355.0",
|
||||
"posthog-js": "^1.363.2",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
@@ -65,19 +65,19 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-i18next": "^16.6.2",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-popopo": "^2.1.9",
|
||||
"react-product-fruits": "^2.2.62",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"recharts": "^3.7.0",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"recharts": "^3.8.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
@@ -85,9 +85,9 @@
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.97.3",
|
||||
"sass": "^1.98.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"styled-components": "^6.3.12",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
@@ -140,7 +140,7 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@dotenvx/dotenvx": "^1.52.0",
|
||||
"@dotenvx/dotenvx": "^1.57.2",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
@@ -156,21 +156,21 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"globals": "^17.3.0",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"memfs": "^4.56.10",
|
||||
"memfs": "^4.57.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.58.2",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-babel": "^1.5.1",
|
||||
"vite-plugin-babel": "^1.6.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest": "^4.1.0",
|
||||
"workbox-window": "^7.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
184
client/src/App/App.container.backup-2026-03-04.jsx
Normal file
184
client/src/App/App.container.backup-2026-03-04.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import { ConfigProvider } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
@@ -43,47 +43,10 @@ function AppContainer() {
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
@@ -159,16 +122,7 @@ function AppContainer() {
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
|
||||
184
client/src/App/App.container.pre-rollback-2026-03-04.jsx
Normal file
184
client/src/App/App.container.pre-rollback-2026-03-04.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -443,38 +443,62 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */
|
||||
.dms-top-panel-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dms-top-panel-col > .ant-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dms-top-panel-col > .ant-card .ant-card-body {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dms-top-panel-col .ant-table-wrapper,
|
||||
.dms-top-panel-col .ant-tabs,
|
||||
.dms-top-panel-col .ant-tabs-content,
|
||||
.dms-top-panel-col .ant-tabs-tabpane {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
//.rbc-time-header-gutter {
|
||||
// padding: 0;
|
||||
//}
|
||||
|
||||
/* globally allow shrink inside table cells */
|
||||
.prod-list-table .ant-table-cell,
|
||||
.prod-list-table .ant-table-cell > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* common AntD offenders */
|
||||
.prod-list-table > .ant-table-cell .ant-space,
|
||||
.ant-table-cell .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Keep your custom header content on the left, push AntD sorter to the far right */
|
||||
.prod-list-table .ant-table-column-sorters {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* allows ellipsis to work */
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-sorter {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
///* globally allow shrink inside table cells */
|
||||
//.prod-list-table .ant-table-cell,
|
||||
//.prod-list-table .ant-table-cell > * {
|
||||
// min-width: 0;
|
||||
//}
|
||||
//
|
||||
///* common AntD offenders */
|
||||
//.prod-list-table > .ant-table-cell .ant-space,
|
||||
//.ant-table-cell .ant-space-item {
|
||||
// min-width: 0;
|
||||
//}
|
||||
//
|
||||
///* Keep your custom header content on the left, push AntD sorter to the far right */
|
||||
//.prod-list-table .ant-table-column-sorters {
|
||||
// display: flex !important;
|
||||
// align-items: center;
|
||||
// width: 100%;
|
||||
//}
|
||||
//
|
||||
//.prod-list-table .ant-table-column-title {
|
||||
// flex: 1 1 auto;
|
||||
// min-width: 0; /* allows ellipsis to work */
|
||||
//}
|
||||
//
|
||||
//.prod-list-table .ant-table-column-sorter {
|
||||
// margin-left: auto;
|
||||
// flex: 0 0 auto;
|
||||
//}
|
||||
|
||||
|
||||
.global-search-autocomplete-fix {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Button } from "antd";
|
||||
import { Button, Card, Divider, Form, Space, Typography } from "antd";
|
||||
import { connect } from "react-redux";
|
||||
import queryString from "query-string";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx";
|
||||
import { TimeTicketTaskModalComponent } from "../time-ticket-task-modal/time-ticket-task-modal.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
@@ -9,8 +13,109 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
setRefundPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "refund_payment" }))
|
||||
});
|
||||
|
||||
const commissionCutFixture = {
|
||||
bodyshop: {
|
||||
features: {
|
||||
timetickets: true
|
||||
},
|
||||
employees: [
|
||||
{ id: "emp-1", first_name: "Avery", last_name: "Johnson" },
|
||||
{ id: "emp-2", first_name: "Morgan", last_name: "Lee" }
|
||||
],
|
||||
md_tasks_presets: {
|
||||
presets: [
|
||||
{
|
||||
name: "Body Prep",
|
||||
percent: 50,
|
||||
hourstype: ["LAA", "LAB"],
|
||||
nextstatus: "In Progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
jobId: "fixture-job-1",
|
||||
joblines: [
|
||||
{
|
||||
id: "line-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
mod_lb_hrs: 4,
|
||||
assigned_team: "team-1",
|
||||
convertedtolbr: false
|
||||
}
|
||||
],
|
||||
previewValues: {
|
||||
task: "Body Prep",
|
||||
timetickets: [
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
cost_center: "Body",
|
||||
ciecacode: "LAA",
|
||||
productivehrs: 2,
|
||||
rate: 40,
|
||||
payoutamount: 80,
|
||||
payout_context: {
|
||||
payout_method: "commission"
|
||||
}
|
||||
},
|
||||
{
|
||||
employeeid: "emp-2",
|
||||
cost_center: "Refinish",
|
||||
ciecacode: "LAB",
|
||||
productivehrs: 1,
|
||||
rate: 28,
|
||||
payoutamount: 28,
|
||||
payout_context: {
|
||||
payout_method: "hourly"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
function CommissionCutHarness() {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Typography.Title level={2}>Commission Cut Test Harness</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
This fixture keeps commission-cut browser checks stable by rendering representative payroll and preview UI with
|
||||
local data.
|
||||
</Typography.Paragraph>
|
||||
<Card title="Payroll Labor Allocations">
|
||||
<PayrollLaborAllocationsTable
|
||||
jobId={commissionCutFixture.jobId}
|
||||
joblines={commissionCutFixture.joblines}
|
||||
timetickets={[]}
|
||||
bodyshop={commissionCutFixture.bodyshop}
|
||||
adjustments={[]}
|
||||
refetch={() => {}}
|
||||
/>
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card title="Claim Task Preview">
|
||||
<Form form={form} initialValues={commissionCutFixture.previewValues} layout="vertical">
|
||||
<TimeTicketTaskModalComponent
|
||||
bodyshop={commissionCutFixture.bodyshop}
|
||||
form={form}
|
||||
loading={false}
|
||||
completedTasks={[]}
|
||||
unassignedHours={1.25}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function Test({ setRefundPaymentContext, refundPaymentModal }) {
|
||||
const search = queryString.parse(useLocation().search);
|
||||
console.log("refundPaymentModal", refundPaymentModal);
|
||||
|
||||
if (search.fixture === "commission-cut") {
|
||||
return <CommissionCutHarness />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DislikeOutlined, LikeOutlined } from "@ant-design/icons";
|
||||
import { Button, Form, Input, Radio, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
function BillAiFeedback({ billForm, rawAIData, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const notification = useNotification();
|
||||
|
||||
//Need to sanitize becuase we pass as form data to include the attachment.
|
||||
const sanitizeBillFormValues = (value) => {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(key, v) => {
|
||||
if (key === "originFileObj") return undefined;
|
||||
if (key === "thumbUrl") return undefined;
|
||||
if (key === "preview") return undefined;
|
||||
if (typeof v === "function") return undefined;
|
||||
if (v && typeof v === "object") {
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
const getAttachmentFromBillFormUpload = () => {
|
||||
const uploads = billForm?.getFieldValue?.("upload") || [];
|
||||
const files = uploads.map((u) => u?.originFileObj).filter(Boolean);
|
||||
|
||||
return (
|
||||
files.find((f) => f?.type === "application/pdf") ||
|
||||
files.find((f) => isString(f?.name) && f.name.toLowerCase().endsWith(".pdf")) ||
|
||||
files[0] ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const submitFeedback = async ({ rating, comments }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const billFormValues = billForm.getFieldsValue(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("rating", rating);
|
||||
formData.append("comments", comments || "");
|
||||
formData.append("billFormValues", sanitizeBillFormValues(billFormValues));
|
||||
formData.append("rawAIData", sanitizeBillFormValues(rawAIData));
|
||||
formData.append("shopname", bodyshop?.shopname || "");
|
||||
|
||||
const attachmentFile = getAttachmentFromBillFormUpload();
|
||||
if (attachmentFile) {
|
||||
formData.append("billPdf", attachmentFile, attachmentFile.name || "bill.pdf");
|
||||
}
|
||||
|
||||
await axios.post("/ai/bill-feedback", formData);
|
||||
|
||||
notification.success({
|
||||
title: "Thanks — feedback submitted"
|
||||
});
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: "Failed to submit feedback",
|
||||
description: error?.response?.data?.message || error?.message
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isString = (v) => typeof v === "string";
|
||||
|
||||
return (
|
||||
<Form form={form} onFinish={submitFeedback} requiredMark={false}>
|
||||
<Space wrap align="top" size="small">
|
||||
<Form.Item name="rating" label={t("bills.labels.ai.feedback_prompt")} rules={[{ required: true }]}>
|
||||
<Radio.Group optionType="button" buttonStyle="solid">
|
||||
<Radio.Button value="up">
|
||||
<LikeOutlined />
|
||||
</Radio.Button>
|
||||
<Radio.Button value="down">
|
||||
<DislikeOutlined />
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap size="small" orientation="vertical">
|
||||
<Form.Item name="comments">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
style={{ minWidth: "400px" }}
|
||||
placeholder={t("bills.labels.ai.feedback_placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button onClick={() => form.submit()} loading={submitting} disabled={submitting}>
|
||||
{t("bills.labels.ai.submit_feedback")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
export default connect(mapStateToProps, null)(BillAiFeedback);
|
||||
@@ -23,7 +23,8 @@ function BillEnterAiScan({
|
||||
fileInputRef,
|
||||
scanLoading,
|
||||
setScanLoading,
|
||||
setIsAiScan
|
||||
setIsAiScan,
|
||||
setRawAIData
|
||||
}) {
|
||||
const notification = useNotification();
|
||||
const { t } = useTranslation();
|
||||
@@ -57,6 +58,7 @@ function BillEnterAiScan({
|
||||
}
|
||||
setScanLoading(false);
|
||||
|
||||
setRawAIData(data.data);
|
||||
// Update form with the extracted data
|
||||
if (data?.data?.billForm) {
|
||||
form.setFieldsValue(data.data.billForm);
|
||||
@@ -108,7 +110,7 @@ function BillEnterAiScan({
|
||||
setIsAiScan(true);
|
||||
const formdata = new FormData();
|
||||
formdata.append("billScan", file);
|
||||
formdata.append("jobid", billEnterModal.context.job?.id);
|
||||
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
|
||||
formdata.append("bodyshopid", bodyshop.id);
|
||||
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
|
||||
|
||||
@@ -147,6 +149,7 @@ function BillEnterAiScan({
|
||||
setScanLoading(false);
|
||||
|
||||
form.setFieldsValue(data.data.billForm);
|
||||
setRawAIData(data.data);
|
||||
await form.validateFields(["billlines"], { recursive: true });
|
||||
|
||||
notification.success({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Modal, Space } from "antd";
|
||||
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -28,6 +28,7 @@ import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
|
||||
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||
import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
billEnterModal: selectBillEnterModal,
|
||||
@@ -53,6 +54,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [isAiScan, setIsAiScan] = useState(false);
|
||||
const [rawAIData, setRawAIData] = useState(null);
|
||||
const client = useApolloClient();
|
||||
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
||||
const notification = useNotification();
|
||||
@@ -387,6 +389,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
billlines: []
|
||||
});
|
||||
setIsAiScan(false);
|
||||
setRawAIData(null);
|
||||
// form.resetFields();
|
||||
} else {
|
||||
toggleModalVisible();
|
||||
@@ -404,6 +407,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
}
|
||||
setScanLoading(false);
|
||||
setIsAiScan(false);
|
||||
setRawAIData(null);
|
||||
toggleModalVisible();
|
||||
}
|
||||
};
|
||||
@@ -429,6 +433,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
}
|
||||
setScanLoading(false);
|
||||
setIsAiScan(false);
|
||||
setRawAIData(null);
|
||||
}
|
||||
}, [billEnterModal.open, form, formValues]);
|
||||
|
||||
@@ -456,6 +461,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
scanLoading={scanLoading}
|
||||
setScanLoading={setScanLoading}
|
||||
setIsAiScan={setIsAiScan}
|
||||
setRawAIData={setRawAIData}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
@@ -471,26 +477,34 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
setLoading(false);
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
|
||||
{t("bills.labels.generatepartslabel")}
|
||||
</Checkbox>
|
||||
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
|
||||
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
{billEnterModal.context && billEnterModal.context.id ? null : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setEnterAgain(true);
|
||||
}}
|
||||
id="save-and-new-bill-enter-modal"
|
||||
>
|
||||
{t("general.actions.saveandnew")}
|
||||
</Button>
|
||||
<Space orientation="vertical">
|
||||
{isAiScan && (
|
||||
<>
|
||||
<BillAiFeedback billForm={form} rawAIData={rawAIData} />
|
||||
<Divider orientation="horizontal" />
|
||||
</>
|
||||
)}
|
||||
<Space wrap align="top">
|
||||
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
|
||||
{t("bills.labels.generatepartslabel")}
|
||||
</Checkbox>
|
||||
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
|
||||
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
{billEnterModal.context && billEnterModal.context.id ? null : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setEnterAgain(true);
|
||||
}}
|
||||
id="save-and-new-bill-enter-modal"
|
||||
>
|
||||
{t("general.actions.saveandnew")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
|
||||
@@ -4,6 +4,7 @@ import dayjs from "../../utils/day";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
isDarkMode: selectDarkMode
|
||||
@@ -19,25 +20,35 @@ export function DmsLogEvents({
|
||||
detailsNonce,
|
||||
isDarkMode,
|
||||
colorizeJson = false,
|
||||
showDetails = true
|
||||
showDetails = true,
|
||||
allowXmlPayload = true
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSet, setOpenSet] = useState(() => new Set());
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
|
||||
// Inject JSON highlight styles once (only when colorize is enabled)
|
||||
useEffect(() => {
|
||||
if (!colorizeJson) return;
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById("json-highlight-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "json-highlight-styles";
|
||||
let style = document.getElementById("json-highlight-styles");
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "json-highlight-styles";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = `
|
||||
.json-key { color: #fa8c16; }
|
||||
.json-string { color: #52c41a; }
|
||||
.json-number { color: #722ed1; }
|
||||
.json-boolean { color: #1890ff; }
|
||||
.json-null { color: #faad14; }
|
||||
.xml-tag { color: #1677ff; }
|
||||
.xml-attr { color: #d46b08; }
|
||||
.xml-value { color: #389e0d; }
|
||||
.xml-decl { color: #7c3aed; }
|
||||
.xml-comment { color: #8c8c8c; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}, [colorizeJson]);
|
||||
|
||||
// Trim openSet if logs shrink
|
||||
@@ -65,6 +76,13 @@ export function DmsLogEvents({
|
||||
// Only treat meta as "present" when we are allowed to show details
|
||||
const hasMeta = !isEmpty(meta) && showDetails;
|
||||
const isOpen = hasMeta && openSet.has(idx);
|
||||
const xml = hasMeta && allowXmlPayload ? extractXmlFromMeta(meta) : { request: null, response: null };
|
||||
const hasRequestXml = !!xml.request;
|
||||
const hasResponseXml = !!xml.response;
|
||||
const copyPayload = hasMeta ? getCopyPayload(meta) : null;
|
||||
const copyPayloadKey = `copy-${idx}`;
|
||||
const copyReqKey = `copy-req-${idx}`;
|
||||
const copyResKey = `copy-res-${idx}`;
|
||||
|
||||
return {
|
||||
key: idx,
|
||||
@@ -92,10 +110,42 @@ export function DmsLogEvents({
|
||||
return next;
|
||||
})
|
||||
}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
||||
>
|
||||
{isOpen ? "Hide details" : "Details"}
|
||||
{isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")}
|
||||
</a>
|
||||
<Divider orientation="vertical" />
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)}
|
||||
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
||||
>
|
||||
{copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")}
|
||||
</a>
|
||||
{hasRequestXml && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => handleCopyAction(copyReqKey, xml.request, setCopiedKey)}
|
||||
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
||||
>
|
||||
{copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasResponseXml && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => handleCopyAction(copyResKey, xml.response, setCopiedKey)}
|
||||
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
||||
>
|
||||
{copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
@@ -103,14 +153,30 @@ export function DmsLogEvents({
|
||||
{/* Row 2: details body (only when open) */}
|
||||
{hasMeta && isOpen && (
|
||||
<div style={{ marginLeft: 6 }}>
|
||||
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
|
||||
<JsonBlock isDarkMode={isDarkMode} data={removeXmlFromMeta(meta)} colorize={colorizeJson} />
|
||||
{hasRequestXml && (
|
||||
<XmlBlock
|
||||
isDarkMode={isDarkMode}
|
||||
title={t("dms.labels.request_xml")}
|
||||
xmlText={xml.request}
|
||||
colorize={colorizeJson}
|
||||
/>
|
||||
)}
|
||||
{hasResponseXml && (
|
||||
<XmlBlock
|
||||
isDarkMode={isDarkMode}
|
||||
title={t("dms.labels.response_xml")}
|
||||
xmlText={xml.response}
|
||||
colorize={colorizeJson}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
};
|
||||
}),
|
||||
[logs, openSet, colorizeJson, isDarkMode, showDetails]
|
||||
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
|
||||
);
|
||||
|
||||
return <Timeline reverse items={items} />;
|
||||
@@ -179,6 +245,121 @@ const safeStringify = (obj, spaces = 2) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get request/response XML from various Reynolds log meta shapes.
|
||||
* @param meta
|
||||
* @returns {{request: string|null, response: string|null}}
|
||||
*/
|
||||
const extractXmlFromMeta = (meta) => {
|
||||
const request =
|
||||
firstString(meta?.requestXml) ||
|
||||
firstString(meta?.xml?.request) ||
|
||||
firstString(meta?.response?.xml?.request) ||
|
||||
firstString(meta?.response?.requestXml);
|
||||
|
||||
const response =
|
||||
firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response);
|
||||
|
||||
return { request, response };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the value to copy when clicking the "Copy" action.
|
||||
* @param meta
|
||||
* @returns {*}
|
||||
*/
|
||||
const getCopyPayload = (meta) => {
|
||||
if (meta?.payload != null) return meta.payload;
|
||||
return meta;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove bulky XML fields from object shown in JSON block (XML is rendered separately).
|
||||
* @param meta
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeXmlFromMeta = (meta) => {
|
||||
if (meta == null || typeof meta !== "object") return meta;
|
||||
const cloned = safeClone(meta);
|
||||
if (cloned == null || typeof cloned !== "object") return meta;
|
||||
|
||||
if (typeof cloned.requestXml === "string") delete cloned.requestXml;
|
||||
if (typeof cloned.responseXml === "string") delete cloned.responseXml;
|
||||
|
||||
if (cloned.xml && typeof cloned.xml === "object") {
|
||||
if (typeof cloned.xml.request === "string") delete cloned.xml.request;
|
||||
if (typeof cloned.xml.response === "string") delete cloned.xml.response;
|
||||
if (isEmpty(cloned.xml)) delete cloned.xml;
|
||||
}
|
||||
|
||||
if (cloned.response?.xml && typeof cloned.response.xml === "object") {
|
||||
if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request;
|
||||
if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response;
|
||||
if (isEmpty(cloned.response.xml)) delete cloned.response.xml;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe deep clone for plain JSON structures.
|
||||
* @param value
|
||||
* @returns {*}
|
||||
*/
|
||||
const safeClone = (value) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* First non-empty string helper.
|
||||
* @param value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const firstString = (value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy arbitrary text/object to clipboard.
|
||||
* @param key
|
||||
* @param value
|
||||
* @param setCopied
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleCopyAction = async (key, value, setCopied) => {
|
||||
const text = typeof value === "string" ? value : safeStringify(value, 2);
|
||||
if (!text) return;
|
||||
const copied = await copyTextToClipboard(text);
|
||||
if (!copied) return;
|
||||
setCopied(key);
|
||||
setTimeout(() => {
|
||||
setCopied((prev) => (prev === key ? null : prev));
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clipboard helper (modern async Clipboard API).
|
||||
* @param text
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON display block with optional syntax highlighting.
|
||||
* @param data
|
||||
@@ -210,6 +391,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => {
|
||||
return <pre style={preStyle}>{jsonText}</pre>;
|
||||
};
|
||||
|
||||
/**
|
||||
* XML display block with normalized indentation.
|
||||
* @param title
|
||||
* @param xmlText
|
||||
* @param isDarkMode
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => {
|
||||
const base = {
|
||||
margin: "8px 0 0",
|
||||
maxWidth: 720,
|
||||
overflowX: "auto",
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
|
||||
border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)",
|
||||
color: isDarkMode ? "var(--card-text-fallback)" : "#141414",
|
||||
whiteSpace: "pre"
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600 }}>{title}</div>
|
||||
{colorize ? (
|
||||
<pre style={base} dangerouslySetInnerHTML={{ __html: highlightXml(formatXml(xmlText)) }} />
|
||||
) : (
|
||||
<pre style={base}>{formatXml(xmlText)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic XML pretty-printer.
|
||||
* @param xml
|
||||
* @returns {string}
|
||||
*/
|
||||
const formatXml = (xml) => {
|
||||
if (typeof xml !== "string") return "";
|
||||
const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*</g, ">\n<").trim();
|
||||
const lines = normalized.split("\n");
|
||||
let indent = 0;
|
||||
const out = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0);
|
||||
out.push(`${" ".repeat(indent)}${line}`);
|
||||
|
||||
const opens = (line.match(/<[^/!?][^>]*>/g) || []).length;
|
||||
const closes = (line.match(/<\/[^>]+>/g) || []).length;
|
||||
const selfClosing = (line.match(/<[^>]+\/>/g) || []).length;
|
||||
const declaration = /^<\?xml/.test(line) ? 1 : 0;
|
||||
|
||||
indent += opens - closes - selfClosing - declaration;
|
||||
if (indent < 0) indent = 0;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Syntax highlight pretty-printed XML text for HTML display.
|
||||
* @param xmlText
|
||||
* @returns {string}
|
||||
*/
|
||||
const highlightXml = (xmlText) => {
|
||||
const esc = String(xmlText || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = esc.split("\n");
|
||||
|
||||
return lines
|
||||
.map((line) => {
|
||||
let out = line;
|
||||
|
||||
out = out.replace(/(<!--[\s\S]*?-->)/g, '<span class="xml-comment">$1</span>');
|
||||
out = out.replace(/(<\?xml[\s\S]*?\?>)/g, '<span class="xml-decl">$1</span>');
|
||||
|
||||
out = out.replace(/(<\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?>)/g, (_m, open, tag, attrs, close) => {
|
||||
const coloredAttrs = attrs.replace(
|
||||
/([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|"[\s\S]*?"|'[\s\S]*?')/g,
|
||||
'<span class="xml-attr">$1</span>$2<span class="xml-value">$3</span>'
|
||||
);
|
||||
return `${open}<span class="xml-tag">${tag}</span>${coloredAttrs}${close}`;
|
||||
});
|
||||
|
||||
return out;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Syntax highlight JSON text for HTML display.
|
||||
* @param jsonText
|
||||
|
||||
@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
||||
const emailsToMenu = {
|
||||
items: [
|
||||
...bodyshop.employees
|
||||
.filter((e) => e.user_email)
|
||||
.filter((e) => e.user_email && e.active === true)
|
||||
.map((e, idx) => ({
|
||||
key: idx,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
||||
const menuCC = {
|
||||
items: [
|
||||
...bodyshop.employees
|
||||
.filter((e) => e.user_email)
|
||||
.filter((e) => e.user_email && e.active === true)
|
||||
.map((e, idx) => ({
|
||||
key: idx,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
|
||||
@@ -25,6 +25,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
if (!e.target) return;
|
||||
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
|
||||
if (bottom && !hasEverScrolledToBottom) {
|
||||
setHasEverScrolledToBottom(true);
|
||||
@@ -36,7 +37,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll({ target: markdownCardRef.current });
|
||||
if (markdownCardRef.current) {
|
||||
handleScroll({ target: markdownCardRef.current });
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
|
||||
@@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
||||
if (!value) return null;
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
switch (type) {
|
||||
case "employee": {
|
||||
const emp = bodyshop.employees.find((e) => e.id === value);
|
||||
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
||||
|
||||
case "text":
|
||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||
case "currency":
|
||||
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
|
||||
case "currency": {
|
||||
const numericValue = toFiniteNumber(value);
|
||||
|
||||
if (numericValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
|
||||
}
|
||||
default:
|
||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DownOutlined, UpOutlined } from "@ant-design/icons";
|
||||
import { Space } from "antd";
|
||||
|
||||
export default function FormListMoveArrows({ move, index, total }) {
|
||||
export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) {
|
||||
const upDisabled = index === 0;
|
||||
const downDisabled = index === total - 1;
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical">
|
||||
<Space orientation={orientation}>
|
||||
<UpOutlined disabled={upDisabled} onClick={handleUp} />
|
||||
<DownOutlined disabled={downDisabled} onClick={handleDown} />
|
||||
</Space>
|
||||
|
||||
@@ -33,7 +33,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import _ from "lodash";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import dayjs from "../../utils/day";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
@@ -49,6 +49,7 @@ import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
||||
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component.jsx";
|
||||
|
||||
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
||||
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
||||
@@ -66,7 +67,8 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly,
|
||||
technician: selectTechnician,
|
||||
isPartsEntry: selectIsPartsEntry
|
||||
isPartsEntry: selectIsPartsEntry,
|
||||
authLevel: selectAuthLevel
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -94,7 +96,8 @@ export function JobLinesComponent({
|
||||
setTaskUpsertContext,
|
||||
billsQuery,
|
||||
handlePartsOrderOnRowClick,
|
||||
isPartsEntry
|
||||
isPartsEntry,
|
||||
authLevel
|
||||
}) {
|
||||
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
||||
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
||||
@@ -386,18 +389,20 @@ export function JobLinesComponent({
|
||||
key: "actions",
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
||||
<Button
|
||||
disabled={jobRO}
|
||||
onClick={() => {
|
||||
setJobLineEditContext({
|
||||
actions: { refetch: refetch, submit: form && form.submit },
|
||||
context: { ...record, jobid: job.id }
|
||||
});
|
||||
}}
|
||||
icon={<EditFilled />}
|
||||
/>
|
||||
)}
|
||||
{(record.manual_line || jobIsPrivate) &&
|
||||
!technician &&
|
||||
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||
<Button
|
||||
disabled={jobRO}
|
||||
onClick={() => {
|
||||
setJobLineEditContext({
|
||||
actions: { refetch: refetch, submit: form && form.submit },
|
||||
context: { ...record, jobid: job.id }
|
||||
});
|
||||
}}
|
||||
icon={<EditFilled />}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
onClick={() => {
|
||||
@@ -410,29 +415,30 @@ export function JobLinesComponent({
|
||||
}}
|
||||
icon={<FaTasks />}
|
||||
/>
|
||||
|
||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
||||
<Button
|
||||
disabled={jobRO}
|
||||
onClick={async () => {
|
||||
await deleteJobLine({
|
||||
variables: { joblineId: record.id },
|
||||
update(cache) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
joblines(existingJobLines, { readField }) {
|
||||
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
||||
{(record.manual_line || jobIsPrivate) &&
|
||||
!technician &&
|
||||
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||
<Button
|
||||
disabled={jobRO}
|
||||
onClick={async () => {
|
||||
await deleteJobLine({
|
||||
variables: { joblineId: record.id },
|
||||
update(cache) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
joblines(existingJobLines, { readField }) {
|
||||
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
await axios.post("/job/totalsssu", { id: job.id });
|
||||
if (refetch) refetch();
|
||||
}}
|
||||
icon={<DeleteFilled />}
|
||||
/>
|
||||
)}
|
||||
});
|
||||
}
|
||||
});
|
||||
await axios.post("/job/totalsssu", { id: job.id });
|
||||
if (refetch) refetch();
|
||||
}}
|
||||
icon={<DeleteFilled />}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -657,7 +663,7 @@ export function JobLinesComponent({
|
||||
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
||||
</Dropdown>
|
||||
|
||||
{!isPartsEntry && (
|
||||
{!isPartsEntry && HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||
<Button
|
||||
disabled={jobRO || technician}
|
||||
onClick={() => {
|
||||
|
||||
@@ -144,18 +144,11 @@ export default function JobTotalsTableLabor({ job }) {
|
||||
{t("jobs.labels.mapa")}
|
||||
{InstanceRenderManager({
|
||||
imex:
|
||||
job.materials?.mapa &&
|
||||
job.materials.mapa.cal_maxdlr &&
|
||||
job.materials.mapa.cal_maxdlr > 0 &&
|
||||
t("jobs.labels.threshhold", {
|
||||
amount: job.materials.mapa.cal_maxdlr
|
||||
}),
|
||||
(job.materials?.mapa ?? job.materials?.MAPA)?.cal_maxdlr > 0 &&
|
||||
t("jobs.labels.threshhold", { amount: (job.materials.mapa ?? job.materials.MAPA).cal_maxdlr }),
|
||||
rome:
|
||||
job.materials?.MAPA &&
|
||||
job.materials.MAPA.cal_maxdlr !== undefined &&
|
||||
t("jobs.labels.threshhold", {
|
||||
amount: job.materials.MAPA.cal_maxdlr
|
||||
})
|
||||
job.materials?.MAPA?.cal_maxdlr !== undefined &&
|
||||
t("jobs.labels.threshhold", { amount: job.materials.MAPA.cal_maxdlr })
|
||||
})}
|
||||
</Space>
|
||||
</ResponsiveTable.Summary.Cell>
|
||||
@@ -190,18 +183,11 @@ export default function JobTotalsTableLabor({ job }) {
|
||||
{t("jobs.labels.mash")}
|
||||
{InstanceRenderManager({
|
||||
imex:
|
||||
job.materials?.mash &&
|
||||
job.materials.mash.cal_maxdlr &&
|
||||
job.materials.mash.cal_maxdlr > 0 &&
|
||||
t("jobs.labels.threshhold", {
|
||||
amount: job.materials.mash.cal_maxdlr
|
||||
}),
|
||||
(job.materials?.mash ?? job.materials?.MASH)?.cal_maxdlr > 0 &&
|
||||
t("jobs.labels.threshhold", { amount: (job.materials.mash ?? job.materials.MASH).cal_maxdlr }),
|
||||
rome:
|
||||
job.materials?.MASH &&
|
||||
job.materials.MASH.cal_maxdlr !== undefined &&
|
||||
t("jobs.labels.threshhold", {
|
||||
amount: job.materials.MASH.cal_maxdlr
|
||||
})
|
||||
job.materials?.MASH?.cal_maxdlr !== undefined &&
|
||||
t("jobs.labels.threshhold", { amount: job.materials.MASH.cal_maxdlr })
|
||||
})}
|
||||
</Space>
|
||||
</ResponsiveTable.Summary.Cell>
|
||||
|
||||
@@ -69,7 +69,9 @@ export function JobsAdminClass({ bodyshop, job }) {
|
||||
</Form>
|
||||
|
||||
<Popconfirm title={t("jobs.labels.changeclass")} onConfirm={() => form.submit()}>
|
||||
<Button loading={loading}>{t("general.actions.save")}</Button>
|
||||
<Button loading={loading} type="primary">
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
|
||||
</LayoutFormRow>
|
||||
</Form>
|
||||
|
||||
<Button loading={loading} onClick={() => form.submit()}>
|
||||
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div>{t("jobs.labels.associationwarning")}</div>
|
||||
<Button loading={loading} onClick={() => form.submit()}>
|
||||
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div>{t("jobs.labels.associationwarning")}</div>
|
||||
<Button loading={loading} onClick={() => form.submit()}>
|
||||
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) =>
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||
}}
|
||||
disabled={jobRO}
|
||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||
@@ -166,7 +166,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) =>
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||
}}
|
||||
disabled={jobRO}
|
||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { LaborAllocationsAdjustmentEdit } from "./labor-allocations-adjustment-edit.component.jsx";
|
||||
|
||||
const updateAdjustmentsMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const notification = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
const insertAuditTrailMock = vi.fn();
|
||||
const jobmodifylbradjMock = vi.fn(() => "audit-entry");
|
||||
|
||||
vi.mock("@apollo/client/react", () => ({
|
||||
useMutation: (...args) => useMutationMock(...args)
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key) => {
|
||||
const translations = {
|
||||
"joblines.fields.mod_lbr_ty": "Labor Type",
|
||||
"joblines.fields.lbr_types.LAA": "LAA",
|
||||
"jobs.fields.adjustmenthours": "Adjustment Hours",
|
||||
"general.actions.save": "Save",
|
||||
"jobs.successes.save": "Saved"
|
||||
};
|
||||
|
||||
return translations[key] || key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||
useNotification: () => notification
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/AuditTrailMappings", () => ({
|
||||
default: {
|
||||
jobmodifylbradj: (...args) => jobmodifylbradjMock(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
describe("LaborAllocationsAdjustmentEdit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useMutationMock.mockImplementation((mutation) => {
|
||||
if (mutation === UPDATE_JOB) {
|
||||
return [updateAdjustmentsMock];
|
||||
}
|
||||
|
||||
return [vi.fn()];
|
||||
});
|
||||
|
||||
updateAdjustmentsMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("saves merged labor adjustments and records the adjustment delta", async () => {
|
||||
render(
|
||||
<LaborAllocationsAdjustmentEdit
|
||||
insertAuditTrail={insertAuditTrailMock}
|
||||
jobId="job-1"
|
||||
mod_lbr_ty="LAA"
|
||||
adjustments={{
|
||||
LAA: 1.2,
|
||||
LAB: 0.5
|
||||
}}
|
||||
refetchQueryNames={["QUERY_JOB"]}
|
||||
>
|
||||
<button type="button">Edit Adjustment</button>
|
||||
</LaborAllocationsAdjustmentEdit>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit Adjustment" }));
|
||||
|
||||
fireEvent.change(screen.getByRole("spinbutton"), {
|
||||
target: { value: "3.7" }
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAdjustmentsMock).toHaveBeenCalledWith({
|
||||
variables: {
|
||||
jobId: "job-1",
|
||||
job: {
|
||||
lbr_adjustments: {
|
||||
LAA: 3.7,
|
||||
LAB: 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_JOB"]
|
||||
});
|
||||
});
|
||||
|
||||
expect(jobmodifylbradjMock).toHaveBeenCalledWith({
|
||||
mod_lbr_ty: "LAA",
|
||||
hours: 2.5
|
||||
});
|
||||
expect(insertAuditTrailMock).toHaveBeenCalledWith({
|
||||
jobid: "job-1",
|
||||
operation: "audit-entry",
|
||||
type: "jobmodifylbradj"
|
||||
});
|
||||
expect(notification.success).toHaveBeenCalledWith({
|
||||
title: "Saved"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({
|
||||
technician: selectTechnician
|
||||
});
|
||||
|
||||
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
|
||||
|
||||
export function PayrollLaborAllocationsTable({
|
||||
jobId,
|
||||
joblines,
|
||||
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
|
||||
});
|
||||
const notification = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
async function CalculateTotals() {
|
||||
const loadTotals = async () => {
|
||||
try {
|
||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
||||
jobid: jobId
|
||||
});
|
||||
setTotals(data);
|
||||
} catch (error) {
|
||||
setTotals([]);
|
||||
notification.error({
|
||||
title: getRequestErrorMessage(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!joblines && !!timetickets && !!bodyshop) {
|
||||
CalculateTotals();
|
||||
loadTotals();
|
||||
}
|
||||
if (!jobId) setTotals([]);
|
||||
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
|
||||
@@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({
|
||||
<Button
|
||||
disabled={!hasTimeTicketAccess}
|
||||
onClick={async () => {
|
||||
const response = await axios.post("/payroll/payall", {
|
||||
jobid: jobId
|
||||
});
|
||||
try {
|
||||
const response = await axios.post("/payroll/payall", {
|
||||
jobid: jobId
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
if (response.data.success !== false) {
|
||||
notification.success({
|
||||
title: t("timetickets.successes.payall")
|
||||
});
|
||||
if (response.status === 200) {
|
||||
if (response.data.success !== false) {
|
||||
notification.success({
|
||||
title: t("timetickets.successes.payall")
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
title: t("timetickets.errors.payall", {
|
||||
error: response.data.error
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (refetch) refetch();
|
||||
} else {
|
||||
notification.error({
|
||||
title: t("timetickets.errors.payall", {
|
||||
error: response.data.error
|
||||
error: JSON.stringify("")
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (refetch) refetch();
|
||||
} else {
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: t("timetickets.errors.payall", {
|
||||
error: JSON.stringify("")
|
||||
error: getRequestErrorMessage(error)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -241,10 +258,7 @@ export function PayrollLaborAllocationsTable({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
||||
jobid: jobId
|
||||
});
|
||||
setTotals(data);
|
||||
await loadTotals();
|
||||
refetch();
|
||||
}}
|
||||
icon={<SyncOutlined />}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import axios from "axios";
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { PayrollLaborAllocationsTable } from "./labor-allocations-table.payroll.component.jsx";
|
||||
|
||||
const notification = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
post: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key, values = {}) => {
|
||||
const translations = {
|
||||
"jobs.labels.laborallocations": "Labor Allocations",
|
||||
"timetickets.actions.payall": "Pay All",
|
||||
"general.labels.totals": "Totals",
|
||||
"jobs.labels.outstandinghours": "Outstanding hours remain."
|
||||
};
|
||||
|
||||
if (key === "timetickets.successes.payall") {
|
||||
return "All hours paid out successfully.";
|
||||
}
|
||||
|
||||
if (key === "timetickets.errors.payall") {
|
||||
return `Error flagging hours. ${values.error || ""}`.trim();
|
||||
}
|
||||
|
||||
return translations[key] || key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||
useNotification: () => notification
|
||||
}));
|
||||
|
||||
vi.mock("../responsive-table/responsive-table.component", () => {
|
||||
function ResponsiveTable({ dataSource = [], summary }) {
|
||||
return (
|
||||
<div data-testid="responsive-table">
|
||||
<div>{`rows:${dataSource.length}`}</div>
|
||||
{summary ? <div>{summary()}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ResponsiveTable.Summary = {
|
||||
Row: ({ children }) => <div>{children}</div>,
|
||||
Cell: ({ children }) => <div>{children}</div>
|
||||
};
|
||||
|
||||
return {
|
||||
default: ResponsiveTable
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../feature-wrapper/feature-wrapper.component", () => ({
|
||||
HasFeatureAccess: () => true
|
||||
}));
|
||||
|
||||
vi.mock("../upsell/upsell.component", () => ({
|
||||
default: () => <div>Upsell</div>,
|
||||
upsellEnum: () => ({
|
||||
timetickets: {
|
||||
allocations: "allocations"
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../lock-wrapper/lock-wrapper.component", () => ({
|
||||
default: ({ children }) => children
|
||||
}));
|
||||
|
||||
describe("PayrollLaborAllocationsTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a success notification after Pay All completes", async () => {
|
||||
axios.post
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
expectedHours: 4,
|
||||
claimedHours: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: [{ id: "ticket-1" }]
|
||||
});
|
||||
|
||||
const refetch = vi.fn();
|
||||
|
||||
render(
|
||||
<PayrollLaborAllocationsTable
|
||||
jobId="job-1"
|
||||
joblines={[{ id: "line-1", convertedtolbr: false }]}
|
||||
timetickets={[]}
|
||||
bodyshop={{
|
||||
features: {
|
||||
timetickets: true
|
||||
},
|
||||
employees: [{ id: "emp-1", first_name: "Avery", last_name: "Johnson" }]
|
||||
}}
|
||||
adjustments={[]}
|
||||
refetch={refetch}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenNthCalledWith(1, "/payroll/calculatelabor", {
|
||||
jobid: "job-1"
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pay All" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenNthCalledWith(2, "/payroll/payall", {
|
||||
jobid: "job-1"
|
||||
});
|
||||
});
|
||||
|
||||
expect(notification.success).toHaveBeenCalledWith({
|
||||
title: "All hours paid out successfully."
|
||||
});
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the returned pay-all error message when payroll rejects the request", async () => {
|
||||
axios.post
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
expectedHours: 4,
|
||||
claimedHours: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
success: false,
|
||||
error: "Not all hours have been assigned."
|
||||
}
|
||||
});
|
||||
|
||||
render(
|
||||
<PayrollLaborAllocationsTable
|
||||
jobId="job-1"
|
||||
joblines={[{ id: "line-1", convertedtolbr: false }]}
|
||||
timetickets={[]}
|
||||
bodyshop={{
|
||||
features: {
|
||||
timetickets: true
|
||||
},
|
||||
employees: [{ id: "emp-1", first_name: "Avery", last_name: "Johnson" }]
|
||||
}}
|
||||
adjustments={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenNthCalledWith(1, "/payroll/calculatelabor", {
|
||||
jobid: "job-1"
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pay All" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notification.error).toHaveBeenCalledWith({
|
||||
title: "Error flagging hours. Not all hours have been assigned."
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,12 @@ export function PartsOrderListTableComponent({
|
||||
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
|
||||
|
||||
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
|
||||
|
||||
const enrichedPartsOrders = parts_orders.map((order) => ({
|
||||
...order,
|
||||
invoice_number: order.bill?.invoice_number
|
||||
}));
|
||||
|
||||
const { refetch } = billsQuery;
|
||||
|
||||
const recordActions = (record, showView = false) => (
|
||||
@@ -222,7 +228,12 @@ export function PartsOrderListTableComponent({
|
||||
dataIndex: "order_number",
|
||||
key: "order_number",
|
||||
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
|
||||
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order
|
||||
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order,
|
||||
render: (text, record) => (
|
||||
<span>
|
||||
{record.order_number} {record.invoice_number && `(${record.invoice_number})`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("parts_orders.fields.order_date"),
|
||||
@@ -272,10 +283,10 @@ export function PartsOrderListTableComponent({
|
||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||
};
|
||||
|
||||
const filteredPartsOrders = parts_orders
|
||||
const filteredPartsOrders = enrichedPartsOrders
|
||||
? searchText === ""
|
||||
? parts_orders
|
||||
: parts_orders.filter(
|
||||
? enrichedPartsOrders
|
||||
: enrichedPartsOrders.filter(
|
||||
(b) =>
|
||||
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Icon from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Input, Popover, Tooltip } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaRegStickyNote } from "react-icons/fa";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
@@ -9,10 +9,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
export default function ProductionListColumnComment({ record, usePortal = false }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [note, setNote] = useState(record.comment || "");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const textAreaRef = useRef(null);
|
||||
const rafIdRef = useRef(null);
|
||||
|
||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||
|
||||
@@ -38,23 +38,35 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
||||
};
|
||||
|
||||
const handleOpenChange = (flag) => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
setOpen(flag);
|
||||
if (flag) setNote(record.comment || "");
|
||||
if (flag) {
|
||||
setNote(record.comment || "");
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
if (textAreaRef.current?.focus) {
|
||||
try {
|
||||
textAreaRef.current.focus({ preventScroll: true });
|
||||
} catch {
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
style={{ width: "30em" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<Input.TextArea
|
||||
id={`job-comment-${record.id}`}
|
||||
name="comment"
|
||||
rows={5}
|
||||
value={note}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
ref={textAreaRef}
|
||||
allowClear
|
||||
style={{ marginBottom: "1em" }}
|
||||
/>
|
||||
@@ -67,13 +79,13 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open}
|
||||
content={content}
|
||||
trigger="click"
|
||||
<Popover
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open}
|
||||
content={content}
|
||||
trigger="click"
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
styles={{ body: { padding: "12px" } }}
|
||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Icon from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Input, Popover, Space } from "antd";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaRegStickyNote } from "react-icons/fa";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
@@ -20,6 +20,8 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
||||
const { t } = useTranslation();
|
||||
const [note, setNote] = useState(record.production_vars?.note || "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const textAreaRef = useRef(null);
|
||||
const rafIdRef = useRef(null);
|
||||
|
||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||
|
||||
@@ -52,25 +54,37 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(flag) => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
setOpen(flag);
|
||||
if (flag) setNote(record.production_vars?.note || "");
|
||||
if (flag) {
|
||||
setNote(record.production_vars?.note || "");
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
if (textAreaRef.current?.focus) {
|
||||
try {
|
||||
textAreaRef.current.focus({ preventScroll: true });
|
||||
} catch {
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[record]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
style={{ width: "30em" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<Input.TextArea
|
||||
id={`job-production-note-${record.id}`}
|
||||
name="production_note"
|
||||
rows={5}
|
||||
value={note}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
ref={textAreaRef}
|
||||
allowClear
|
||||
style={{ marginBottom: "1em" }}
|
||||
/>
|
||||
@@ -96,13 +110,13 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open}
|
||||
content={content}
|
||||
trigger="click"
|
||||
<Popover
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open}
|
||||
content={content}
|
||||
trigger="click"
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
styles={{ body: { padding: "12px" } }}
|
||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -26,6 +26,7 @@ const ret = {
|
||||
"jobs:partsqueue": 4,
|
||||
"jobs:checklist-view": 2,
|
||||
"jobs:list-ready": 1,
|
||||
"jobs:manual-line": 1,
|
||||
"jobs:void": 5,
|
||||
|
||||
"bills:enter": 2,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Grid, Table } from "antd";
|
||||
import { useMemo } from "react";
|
||||
import "./responsive-table.styles.scss";
|
||||
|
||||
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isCompactViewport = !screens.lg;
|
||||
const prefersHorizontalScroll = isPhone || isCompactViewport;
|
||||
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
|
||||
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
|
||||
const resolvedColumns = useMemo(() => {
|
||||
if (
|
||||
!isResponsiveFilteringEnabled ||
|
||||
!Array.isArray(columns) ||
|
||||
!isPhone ||
|
||||
!Array.isArray(mobileColumnKeys) ||
|
||||
mobileColumnKeys.length === 0
|
||||
) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const visibleColumnKeys = new Set(mobileColumnKeys);
|
||||
const filteredColumns = columns.filter((column) => {
|
||||
const key = column?.key ?? column?.dataIndex;
|
||||
|
||||
// Keep columns with no stable key to avoid accidental loss.
|
||||
if (key == null) return true;
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
return key.some((part) => visibleColumnKeys.has(part));
|
||||
}
|
||||
|
||||
return visibleColumnKeys.has(key);
|
||||
});
|
||||
|
||||
return filteredColumns.length > 0 ? filteredColumns : columns;
|
||||
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
|
||||
|
||||
const resolvedScroll = useMemo(() => {
|
||||
if (prefersHorizontalScroll) {
|
||||
if (scroll == null) {
|
||||
return { x: "max-content" };
|
||||
}
|
||||
|
||||
if (typeof scroll !== "object" || Array.isArray(scroll)) {
|
||||
return scroll;
|
||||
}
|
||||
|
||||
const { x, ...baseScroll } = scroll;
|
||||
|
||||
return { ...baseScroll, x: x ?? "max-content" };
|
||||
}
|
||||
|
||||
if (scroll == null) {
|
||||
// Explicitly override ConfigProvider table.scroll desktop defaults.
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof scroll !== "object" || Array.isArray(scroll)) {
|
||||
return scroll;
|
||||
}
|
||||
|
||||
const { x, ...desktopScroll } = scroll;
|
||||
|
||||
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
|
||||
if (x == null) {
|
||||
return desktopScroll;
|
||||
}
|
||||
|
||||
return desktopScroll;
|
||||
}, [prefersHorizontalScroll, scroll]);
|
||||
|
||||
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
|
||||
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
|
||||
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={resolvedClassName}
|
||||
columns={resolvedColumns}
|
||||
scroll={resolvedScroll}
|
||||
tableLayout={resolvedTableLayout}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ResponsiveTable.Summary = Table.Summary;
|
||||
ResponsiveTable.Column = Table.Column;
|
||||
ResponsiveTable.ColumnGroup = Table.ColumnGroup;
|
||||
ResponsiveTable.SELECTION_COLUMN = Table.SELECTION_COLUMN;
|
||||
ResponsiveTable.EXPAND_COLUMN = Table.EXPAND_COLUMN;
|
||||
|
||||
export default ResponsiveTable;
|
||||
@@ -1,93 +1,7 @@
|
||||
import { Grid, Table } from "antd";
|
||||
import { useMemo } from "react";
|
||||
import "./responsive-table.styles.scss";
|
||||
import { Table } from "antd";
|
||||
|
||||
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isCompactViewport = !screens.lg;
|
||||
const prefersHorizontalScroll = isPhone || isCompactViewport;
|
||||
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
|
||||
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
|
||||
const resolvedColumns = useMemo(() => {
|
||||
if (
|
||||
!isResponsiveFilteringEnabled ||
|
||||
!Array.isArray(columns) ||
|
||||
!isPhone ||
|
||||
!Array.isArray(mobileColumnKeys) ||
|
||||
mobileColumnKeys.length === 0
|
||||
) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const visibleColumnKeys = new Set(mobileColumnKeys);
|
||||
const filteredColumns = columns.filter((column) => {
|
||||
const key = column?.key ?? column?.dataIndex;
|
||||
|
||||
// Keep columns with no stable key to avoid accidental loss.
|
||||
if (key == null) return true;
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
return key.some((part) => visibleColumnKeys.has(part));
|
||||
}
|
||||
|
||||
return visibleColumnKeys.has(key);
|
||||
});
|
||||
|
||||
return filteredColumns.length > 0 ? filteredColumns : columns;
|
||||
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
|
||||
|
||||
const resolvedScroll = useMemo(() => {
|
||||
if (prefersHorizontalScroll) {
|
||||
if (scroll == null) {
|
||||
return { x: "max-content" };
|
||||
}
|
||||
|
||||
if (typeof scroll !== "object" || Array.isArray(scroll)) {
|
||||
return scroll;
|
||||
}
|
||||
|
||||
const { x, ...baseScroll } = scroll;
|
||||
|
||||
return { ...baseScroll, x: x ?? "max-content" };
|
||||
}
|
||||
|
||||
if (scroll == null) {
|
||||
// Explicitly override ConfigProvider table.scroll desktop defaults.
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof scroll !== "object" || Array.isArray(scroll)) {
|
||||
return scroll;
|
||||
}
|
||||
|
||||
const { x, ...desktopScroll } = scroll;
|
||||
|
||||
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
|
||||
if (x == null) {
|
||||
return desktopScroll;
|
||||
}
|
||||
|
||||
return desktopScroll;
|
||||
}, [prefersHorizontalScroll, scroll]);
|
||||
|
||||
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
|
||||
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
|
||||
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={resolvedClassName}
|
||||
columns={resolvedColumns}
|
||||
scroll={resolvedScroll}
|
||||
tableLayout={resolvedTableLayout}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
function ResponsiveTable(props) {
|
||||
return <Table {...props} />;
|
||||
}
|
||||
|
||||
ResponsiveTable.Summary = Table.Summary;
|
||||
|
||||
@@ -435,6 +435,19 @@ export function ShopInfoRbacComponent({ bodyshop }) {
|
||||
>
|
||||
<InputNumber />
|
||||
</Form.Item>,
|
||||
<Form.Item
|
||||
key="jobs:manual-line"
|
||||
label={t("bodyshop.fields.rbac.jobs.manual-line")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
name={["md_rbac", "jobs:manual-line"]}
|
||||
>
|
||||
<InputNumber />
|
||||
</Form.Item>,
|
||||
<Form.Item
|
||||
key="jobs:partsqueue"
|
||||
label={t("bodyshop.fields.rbac.jobs.partsqueue")}
|
||||
|
||||
@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
|
||||
|
||||
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
|
||||
|
||||
const getTaskPresetAllocationErrors = (presets = [], t) => {
|
||||
const totalsByLaborType = {};
|
||||
|
||||
presets.forEach((preset) => {
|
||||
const percent = normalizePercent(preset?.percent);
|
||||
|
||||
if (!percent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const laborTypes = Array.isArray(preset?.hourstype) ? preset.hourstype : [];
|
||||
|
||||
laborTypes.forEach((laborType) => {
|
||||
if (!laborType) {
|
||||
return;
|
||||
}
|
||||
|
||||
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.entries(totalsByLaborType)
|
||||
.filter(([, total]) => total > 100)
|
||||
.map(([laborType, total]) => {
|
||||
const translatedLaborType = t(`joblines.fields.lbr_types.${laborType}`);
|
||||
const laborTypeLabel =
|
||||
translatedLaborType === `joblines.fields.lbr_types.${laborType}` ? laborType : translatedLaborType;
|
||||
|
||||
return t("bodyshop.errors.task_preset_allocation_exceeded", {
|
||||
laborType: laborTypeLabel,
|
||||
total
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export function ShopInfoTaskPresets({ bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
|
||||
</LayoutFormRow>
|
||||
|
||||
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
|
||||
<Form.List name={["md_tasks_presets", "presets"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
<Form.List
|
||||
name={["md_tasks_presets", "presets"]}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, presets) => {
|
||||
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
|
||||
|
||||
if (allocationErrors.length > 0) {
|
||||
throw new Error(allocationErrors.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove, move }, { errors }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography
|
||||
} from "antd";
|
||||
|
||||
import querystring from "query-string";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -22,13 +36,51 @@ import {
|
||||
} from "../../graphql/employee_teams.queries";
|
||||
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import {
|
||||
LABOR_TYPES,
|
||||
getSplitTotal,
|
||||
hasExactSplitTotal,
|
||||
normalizeEmployeeTeam,
|
||||
normalizeTeamMember,
|
||||
validateEmployeeTeamMembers
|
||||
} from "./shop-employee-teams.form.utils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
const PAYOUT_METHOD_OPTIONS = [
|
||||
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
|
||||
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
|
||||
];
|
||||
|
||||
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
|
||||
employee: { xs: 24, lg: 13, xxl: 14 },
|
||||
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
|
||||
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
|
||||
};
|
||||
|
||||
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
|
||||
|
||||
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
|
||||
|
||||
const getEmployeeDisplayName = (employees = [], employeeId) => {
|
||||
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
|
||||
if (!employee) return null;
|
||||
|
||||
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
|
||||
return fullName || employee.employee_number || null;
|
||||
};
|
||||
|
||||
const formatAllocationPercentage = (percentage) => {
|
||||
if (percentage === null || percentage === undefined || percentage === "") return null;
|
||||
|
||||
const numericValue = Number(percentage);
|
||||
if (!Number.isFinite(numericValue)) return null;
|
||||
|
||||
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
};
|
||||
|
||||
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,47 +88,110 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
const history = useNavigate();
|
||||
const search = querystring.parse(useLocation().search);
|
||||
const notification = useNotification();
|
||||
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
|
||||
const isNewTeam = search.employeeTeamId === "new";
|
||||
|
||||
const { error, data } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
||||
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
||||
variables: { id: search.employeeTeamId },
|
||||
skip: !search.employeeTeamId || search.employeeTeamId === "new",
|
||||
skip: !search.employeeTeamId || isNewTeam,
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
nextFetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
|
||||
else {
|
||||
if (!search.employeeTeamId) return;
|
||||
|
||||
if (isNewTeam) {
|
||||
form.resetFields();
|
||||
setHydratedTeamId("new");
|
||||
return;
|
||||
}
|
||||
}, [form, data, search.employeeTeamId]);
|
||||
|
||||
setHydratedTeamId(null);
|
||||
}, [form, isNewTeam, search.employeeTeamId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.employeeTeamId || isNewTeam || loading) return;
|
||||
|
||||
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
|
||||
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
|
||||
setHydratedTeamId(search.employeeTeamId);
|
||||
} else {
|
||||
form.resetFields();
|
||||
setHydratedTeamId(search.employeeTeamId);
|
||||
}
|
||||
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
|
||||
|
||||
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
||||
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
|
||||
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
|
||||
label: t(labelKey),
|
||||
value
|
||||
}));
|
||||
const teamName = Form.useWatch("name", form);
|
||||
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
|
||||
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
|
||||
const teamCardTitle = isTeamHydrating
|
||||
? t("employee_teams.fields.name")
|
||||
: teamName?.trim() || t("employee_teams.fields.name");
|
||||
|
||||
const getTeamMemberTitle = (teamMember = {}) => {
|
||||
const employeeName =
|
||||
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
|
||||
const allocation = formatAllocationPercentage(teamMember.percentage);
|
||||
const payoutMethod =
|
||||
teamMember.payout_method === "commission"
|
||||
? t("employee_teams.options.commission")
|
||||
: t("employee_teams.options.hourly");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
||||
<Typography.Text strong>{employeeName}</Typography.Text>
|
||||
<Tag variant="filled" color="geekblue">
|
||||
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
|
||||
</Tag>
|
||||
<Tag variant="filled" color={getPayoutMethodTagColor(teamMember.payout_method)}>
|
||||
{payoutMethod}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleFinish = async ({ employee_team_members = [], ...values }) => {
|
||||
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
|
||||
|
||||
if (errorKey) {
|
||||
notification.error({
|
||||
title: t(errorKey)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFinish = async ({ employee_team_members, ...values }) => {
|
||||
if (search.employeeTeamId && search.employeeTeamId !== "new") {
|
||||
//Update a record.
|
||||
logImEXEvent("shop_employee_update");
|
||||
|
||||
const result = await updateEmployeeTeam({
|
||||
variables: {
|
||||
employeeTeamId: search.employeeTeamId,
|
||||
employeeTeam: values,
|
||||
teamMemberUpdates: employee_team_members
|
||||
.filter((e) => e.id)
|
||||
.map((e) => {
|
||||
delete e.__typename;
|
||||
return { where: { id: { _eq: e.id } }, _set: e };
|
||||
}),
|
||||
teamMemberInserts: employee_team_members
|
||||
.filter((e) => e.id === null || e.id === undefined)
|
||||
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
|
||||
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
|
||||
(e) => !employee_team_members.find((etm) => etm.id === e.id)
|
||||
)
|
||||
teamMemberUpdates: normalizedTeamMembers
|
||||
.filter((teamMember) => teamMember.id)
|
||||
.map((teamMember) => ({
|
||||
where: { id: { _eq: teamMember.id } },
|
||||
_set: teamMember
|
||||
})),
|
||||
teamMemberInserts: normalizedTeamMembers
|
||||
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
|
||||
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
|
||||
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
|
||||
.filter(
|
||||
(teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
|
||||
)
|
||||
.map((teamMember) => teamMember.id)
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
@@ -89,20 +204,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
//New record, insert it.
|
||||
logImEXEvent("shop_employee_insert");
|
||||
|
||||
insertEmployeeTeam({
|
||||
variables: {
|
||||
employeeTeam: {
|
||||
...values,
|
||||
employee_team_members: { data: employee_team_members },
|
||||
employee_team_members: { data: normalizedTeamMembers },
|
||||
bodyshopid: bodyshop.id
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_TEAMS"]
|
||||
}).then((r) => {
|
||||
search.employeeTeamId = r.data.insert_employee_teams_one.id;
|
||||
}).then((response) => {
|
||||
search.employeeTeamId = response.data.insert_employee_teams_one.id;
|
||||
history({ search: querystring.stringify(search) });
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
@@ -116,288 +230,196 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={teamCardTitle}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => form.submit()}>
|
||||
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<LayoutFormRow>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t("employee_teams.fields.name")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.max_load")}
|
||||
name="max_load"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Form.List name={["employee_team_members"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
||||
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.employeeid")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "employeeid"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.percentage")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "percentage"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAA")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAA"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAB")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAB"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAD")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAD"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAE")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAE"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
{isTeamHydrating ? (
|
||||
<Skeleton active title={false} paragraph={{ rows: 12 }} />
|
||||
) : (
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<LayoutFormRow>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t("employee_teams.fields.name")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.max_load")}
|
||||
name="max_load"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Form.List name={["employee_team_members"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
const teamMember = normalizeTeamMember(teamMembers[field.name]);
|
||||
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAF")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAF"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
return (
|
||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
||||
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<LayoutFormRow
|
||||
grow
|
||||
title={getTeamMemberTitle(teamMember)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
>
|
||||
<div>
|
||||
<Row gutter={[16, 0]}>
|
||||
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.employeeid")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "employeeid"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.allocation_percentage")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "percentage"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
|
||||
<Form.Item
|
||||
label={t("employee_teams.fields.payout_method")}
|
||||
key={`${index}-payout-method`}
|
||||
name={[field.name, "payout_method"]}
|
||||
initialValue="hourly"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select options={payoutMethodOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
|
||||
{() => {
|
||||
const payoutMethod =
|
||||
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
|
||||
"hourly";
|
||||
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 0]}>
|
||||
{LABOR_TYPES.map((laborType) => (
|
||||
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
|
||||
<Form.Item
|
||||
label={t(`joblines.fields.lbr_types.${laborType}`)}
|
||||
name={[field.name, fieldName, laborType]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
{payoutMethod === "commission" ? (
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
) : (
|
||||
<CurrencyInput />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAG")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAG"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAM")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAM"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAR")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAR"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAS")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAS"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LAU")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LAU"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LA1")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LA1"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LA2")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LA2"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LA3")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LA3"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.lbr_types.LA4")}
|
||||
key={`${index}`}
|
||||
name={[field.name, "labor_rates", "LA4"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
<Space align="center">
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</LayoutFormRow>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
add({
|
||||
percentage: 0,
|
||||
payout_method: "hourly",
|
||||
labor_rates: {},
|
||||
commission_rates: {}
|
||||
});
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("employee_teams.actions.newmember")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
add();
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
|
||||
const splitTotal = getSplitTotal(teamMembers);
|
||||
|
||||
return (
|
||||
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
|
||||
{t("employee_teams.labels.allocation_total", {
|
||||
total: splitTotal.toFixed(2)
|
||||
})}
|
||||
</Typography.Text>
|
||||
);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("employee_teams.actions.newmember")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INSERT_EMPLOYEE_TEAM, UPDATE_EMPLOYEE_TEAM } from "../../graphql/employee_teams.queries";
|
||||
import { LABOR_TYPES } from "./shop-employee-teams.form.utils.js";
|
||||
import { ShopEmployeeTeamsFormComponent } from "./shop-employee-teams.form.component.jsx";
|
||||
|
||||
const insertEmployeeTeamMock = vi.fn();
|
||||
const updateEmployeeTeamMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
const notification = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock("@apollo/client/react", () => ({
|
||||
useQuery: (...args) => useQueryMock(...args),
|
||||
useMutation: (...args) => useMutationMock(...args)
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useLocation: () => ({
|
||||
search: "?employeeTeamId=new"
|
||||
}),
|
||||
useNavigate: () => navigateMock
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key, values = {}) => {
|
||||
const translations = {
|
||||
"employee_teams.fields.name": "Team Name",
|
||||
"employee_teams.fields.active": "Active",
|
||||
"employee_teams.fields.max_load": "Max Load",
|
||||
"employee_teams.fields.employeeid": "Employee",
|
||||
"employee_teams.fields.allocation_percentage": "Allocation %",
|
||||
"employee_teams.fields.payout_method": "Payout Method",
|
||||
"employee_teams.fields.allocation": "Allocation",
|
||||
"employee_teams.fields.employeeid_label": "Employee",
|
||||
"employee_teams.options.hourly": "Hourly",
|
||||
"employee_teams.options.commission": "Commission",
|
||||
"employee_teams.options.commission_percentage": "Commission",
|
||||
"employee_teams.actions.newmember": "New Team Member",
|
||||
"employee_teams.errors.minimum_one_member": "Add at least one team member.",
|
||||
"employee_teams.errors.duplicate_member": "Team members must be unique.",
|
||||
"employee_teams.errors.allocation_total_exact": "Allocation must total exactly 100%.",
|
||||
"general.actions.save": "Save",
|
||||
"employees.successes.save": "Saved"
|
||||
};
|
||||
|
||||
if (key === "employee_teams.labels.allocation_total") {
|
||||
return `Allocation Total: ${values.total}%`;
|
||||
}
|
||||
|
||||
if (key.startsWith("joblines.fields.lbr_types.")) {
|
||||
return key.split(".").pop();
|
||||
}
|
||||
|
||||
return translations[key] || key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||
useNotification: () => notification
|
||||
}));
|
||||
|
||||
vi.mock("../../firebase/firebase.utils", () => ({
|
||||
logImEXEvent: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../employee-search-select/employee-search-select.component", () => ({
|
||||
default: ({ id, value, onChange, options = [] }) => (
|
||||
<select
|
||||
aria-label="Employee"
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange?.(event.target.value || undefined)}
|
||||
>
|
||||
<option value="">Select Employee</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{[option.first_name, option.last_name].filter(Boolean).join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../form-items-formatted/currency-form-item.component", () => ({
|
||||
default: ({ id, value, onChange }) => (
|
||||
<input
|
||||
data-testid="currency-input"
|
||||
id={id}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange?.(event.target.value === "" ? null : Number(event.target.value))}
|
||||
/>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../layout-form-row/layout-form-row.component", () => ({
|
||||
default: ({ title, extra, children }) => (
|
||||
<div>
|
||||
{title}
|
||||
{extra}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
const bodyshop = {
|
||||
id: "shop-1",
|
||||
employees: [
|
||||
{
|
||||
id: "emp-1",
|
||||
first_name: "Avery",
|
||||
last_name: "Johnson"
|
||||
},
|
||||
{
|
||||
id: "emp-2",
|
||||
first_name: "Morgan",
|
||||
last_name: "Lee"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const fillHourlyRates = (value) => {
|
||||
LABOR_TYPES.forEach((laborType) => {
|
||||
fireEvent.change(screen.getByLabelText(laborType), {
|
||||
target: { value: String(value) }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const addBaseTeamMember = ({ employeeId = "emp-1", percentage = 100, rate = 25 } = {}) => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "New Team Member" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Employee"), {
|
||||
target: { value: employeeId }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Allocation %" }), {
|
||||
target: { value: String(percentage) }
|
||||
});
|
||||
fillHourlyRates(rate);
|
||||
};
|
||||
|
||||
describe("ShopEmployeeTeamsFormComponent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useQueryMock.mockReturnValue({
|
||||
error: null,
|
||||
data: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
useMutationMock.mockImplementation((mutation) => {
|
||||
if (mutation === UPDATE_EMPLOYEE_TEAM) {
|
||||
return [updateEmployeeTeamMock];
|
||||
}
|
||||
|
||||
if (mutation === INSERT_EMPLOYEE_TEAM) {
|
||||
return [insertEmployeeTeamMock];
|
||||
}
|
||||
|
||||
return [vi.fn()];
|
||||
});
|
||||
|
||||
insertEmployeeTeamMock.mockResolvedValue({
|
||||
data: {
|
||||
insert_employee_teams_one: {
|
||||
id: "team-1"
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("switches a new team member from hourly rates to commission percentages", async () => {
|
||||
render(<ShopEmployeeTeamsFormComponent bodyshop={bodyshop} />);
|
||||
|
||||
addBaseTeamMember();
|
||||
expect(screen.getAllByTestId("currency-input")).toHaveLength(LABOR_TYPES.length);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Payout Method" }));
|
||||
fireEvent.click(screen.getByText("Commission"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId("currency-input")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a valid new hourly team with normalized member data", async () => {
|
||||
render(<ShopEmployeeTeamsFormComponent bodyshop={bodyshop} />);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Team Name" }), {
|
||||
target: { value: "Commission Crew" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Max Load" }), {
|
||||
target: { value: "8" }
|
||||
});
|
||||
|
||||
addBaseTeamMember({
|
||||
employeeId: "emp-1",
|
||||
percentage: 100,
|
||||
rate: 27.5
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(insertEmployeeTeamMock).toHaveBeenCalledWith({
|
||||
variables: {
|
||||
employeeTeam: {
|
||||
name: "Commission Crew",
|
||||
max_load: 8,
|
||||
employee_team_members: {
|
||||
data: [
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
percentage: 100,
|
||||
payout_method: "hourly",
|
||||
labor_rates: Object.fromEntries(LABOR_TYPES.map((laborType) => [laborType, 27.5])),
|
||||
commission_rates: {}
|
||||
}
|
||||
]
|
||||
},
|
||||
bodyshopid: "shop-1"
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_TEAMS"]
|
||||
});
|
||||
});
|
||||
|
||||
expect(notification.success).toHaveBeenCalledWith({
|
||||
title: "Saved"
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
search: "employeeTeamId=team-1"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export const LABOR_TYPES = [
|
||||
"LAA",
|
||||
"LAB",
|
||||
"LAD",
|
||||
"LAE",
|
||||
"LAF",
|
||||
"LAG",
|
||||
"LAM",
|
||||
"LAR",
|
||||
"LAS",
|
||||
"LAU",
|
||||
"LA1",
|
||||
"LA2",
|
||||
"LA3",
|
||||
"LA4"
|
||||
];
|
||||
|
||||
export const normalizeTeamMember = (teamMember = {}) => ({
|
||||
...teamMember,
|
||||
payout_method: teamMember.payout_method || "hourly",
|
||||
labor_rates: teamMember.labor_rates || {},
|
||||
commission_rates: teamMember.commission_rates || {}
|
||||
});
|
||||
|
||||
export const normalizeEmployeeTeam = (employeeTeam = {}) => ({
|
||||
...employeeTeam,
|
||||
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
|
||||
});
|
||||
|
||||
export const getSplitTotal = (teamMembers = []) =>
|
||||
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
|
||||
|
||||
export const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
|
||||
|
||||
export const validateEmployeeTeamMembers = (employeeTeamMembers = []) => {
|
||||
const normalizedTeamMembers = employeeTeamMembers.map((teamMember) => {
|
||||
const nextTeamMember = normalizeTeamMember({ ...teamMember });
|
||||
delete nextTeamMember.__typename;
|
||||
return nextTeamMember;
|
||||
});
|
||||
|
||||
if (normalizedTeamMembers.length === 0) {
|
||||
return {
|
||||
normalizedTeamMembers,
|
||||
errorKey: "employee_teams.errors.minimum_one_member"
|
||||
};
|
||||
}
|
||||
|
||||
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
|
||||
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
|
||||
|
||||
if (duplicateEmployeeIds.length > 0) {
|
||||
return {
|
||||
normalizedTeamMembers,
|
||||
errorKey: "employee_teams.errors.duplicate_member"
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasExactSplitTotal(normalizedTeamMembers)) {
|
||||
return {
|
||||
normalizedTeamMembers,
|
||||
errorKey: "employee_teams.errors.allocation_total_exact"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedTeamMembers,
|
||||
errorKey: null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSplitTotal,
|
||||
hasExactSplitTotal,
|
||||
normalizeTeamMember,
|
||||
validateEmployeeTeamMembers
|
||||
} from "./shop-employee-teams.form.utils.js";
|
||||
|
||||
describe("shop employee team form utilities", () => {
|
||||
it("normalizes missing payout defaults for a team member", () => {
|
||||
expect(
|
||||
normalizeTeamMember({
|
||||
employeeid: "emp-1",
|
||||
percentage: 100
|
||||
})
|
||||
).toEqual({
|
||||
employeeid: "emp-1",
|
||||
percentage: 100,
|
||||
payout_method: "hourly",
|
||||
labor_rates: {},
|
||||
commission_rates: {}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a minimum-member validation error when no team members are provided", () => {
|
||||
expect(validateEmployeeTeamMembers([])).toEqual({
|
||||
normalizedTeamMembers: [],
|
||||
errorKey: "employee_teams.errors.minimum_one_member"
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate employees in the same team", () => {
|
||||
const result = validateEmployeeTeamMembers([
|
||||
{ employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 25 } },
|
||||
{ employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 30 } }
|
||||
]);
|
||||
|
||||
expect(result.errorKey).toBe("employee_teams.errors.duplicate_member");
|
||||
});
|
||||
|
||||
it("rejects team allocations that do not add up to exactly one hundred percent", () => {
|
||||
const result = validateEmployeeTeamMembers([
|
||||
{ employeeid: "emp-1", percentage: 60, labor_rates: { LAA: 25 } },
|
||||
{ employeeid: "emp-2", percentage: 30, labor_rates: { LAA: 30 } }
|
||||
]);
|
||||
|
||||
expect(getSplitTotal(result.normalizedTeamMembers)).toBe(90);
|
||||
expect(hasExactSplitTotal(result.normalizedTeamMembers)).toBe(false);
|
||||
expect(result.errorKey).toBe("employee_teams.errors.allocation_total_exact");
|
||||
});
|
||||
|
||||
it("accepts a valid mixed hourly and commission team and strips graph metadata", () => {
|
||||
const result = validateEmployeeTeamMembers([
|
||||
{
|
||||
__typename: "employee_team_members",
|
||||
employeeid: "emp-1",
|
||||
percentage: 40,
|
||||
labor_rates: { LAA: 28.5 }
|
||||
},
|
||||
{
|
||||
employeeid: "emp-2",
|
||||
percentage: 60,
|
||||
payout_method: "commission",
|
||||
commission_rates: { LAA: 35 }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(result.errorKey).toBeNull();
|
||||
expect(result.normalizedTeamMembers).toEqual([
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
percentage: 40,
|
||||
payout_method: "hourly",
|
||||
labor_rates: { LAA: 28.5 },
|
||||
commission_rates: {}
|
||||
},
|
||||
{
|
||||
employeeid: "emp-2",
|
||||
percentage: 60,
|
||||
payout_method: "commission",
|
||||
labor_rates: {},
|
||||
commission_rates: { LAA: 35 }
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -66,10 +66,9 @@ export function TechClockInContainer({ setTimeTicketContext, technician, bodysho
|
||||
employeeid: technician.id,
|
||||
date:
|
||||
typeof bodyshop.timezone === "string"
|
||||
? // TODO: Client Update - This may be broken
|
||||
dayjs.tz(theTime, bodyshop.timezone).format("YYYY-MM-DD")
|
||||
? dayjs(theTime).tz(bodyshop.timezone).format("YYYY-MM-DD")
|
||||
: typeof bodyshop.timezone === "number"
|
||||
? dayjs(theTime).format("YYYY-MM-DD").utcOffset(bodyshop.timezone)
|
||||
? dayjs(theTime).utcOffset(bodyshop.timezone).format("YYYY-MM-DD")
|
||||
: dayjs(theTime).format("YYYY-MM-DD"),
|
||||
clockon: dayjs(theTime),
|
||||
jobid: values.jobid,
|
||||
|
||||
@@ -25,10 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
});
|
||||
|
||||
export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
|
||||
const breakpoints = Grid.useBreakpoint();
|
||||
const selectedBreakpoint = Object.entries(breakpoints)
|
||||
.filter(([, isOn]) => !!isOn)
|
||||
.slice(-1)[0];
|
||||
const screens = Grid.useBreakpoint();
|
||||
|
||||
const bpoints = {
|
||||
xs: "100%",
|
||||
@@ -36,10 +33,16 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
|
||||
md: "100%",
|
||||
lg: "100%",
|
||||
xl: "90%",
|
||||
xxl: "85%"
|
||||
xxl: "90%"
|
||||
};
|
||||
|
||||
const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] : "100%";
|
||||
let drawerPercentage = "100%";
|
||||
if (screens.xxl) drawerPercentage = bpoints.xxl;
|
||||
else if (screens.xl) drawerPercentage = bpoints.xl;
|
||||
else if (screens.lg) drawerPercentage = bpoints.lg;
|
||||
else if (screens.md) drawerPercentage = bpoints.md;
|
||||
else if (screens.sm) drawerPercentage = bpoints.sm;
|
||||
else if (screens.xs) drawerPercentage = bpoints.xs;
|
||||
|
||||
const location = useLocation();
|
||||
const history = useNavigate();
|
||||
|
||||
@@ -47,7 +47,7 @@ export function TimeTicketModalComponent({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
|
||||
@@ -347,7 +347,7 @@ export function LaborAllocationContainer({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
|
||||
@@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent);
|
||||
|
||||
const getPayoutMethodLabel = (payoutMethod, t) => {
|
||||
if (!payoutMethod) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (payoutMethod === "hourly" || payoutMethod === "commission") {
|
||||
return t(`timetickets.labels.payout_methods.${payoutMethod}`);
|
||||
}
|
||||
|
||||
return payoutMethod;
|
||||
};
|
||||
|
||||
export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -35,7 +47,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
|
||||
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Form.Item name="task" label={t("timetickets.labels.task")}>
|
||||
<Form.Item
|
||||
name="task"
|
||||
label={t("timetickets.labels.task")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
@@ -93,33 +113,51 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
|
||||
<th>{t("timetickets.fields.cost_center")}</th>
|
||||
<th>{t("timetickets.fields.ciecacode")}</th>
|
||||
<th>{t("timetickets.fields.productivehrs")}</th>
|
||||
<th>{t("timetickets.fields.payout_method")}</th>
|
||||
<th>{t("timetickets.fields.rate")}</th>
|
||||
<th>{t("timetickets.fields.amount")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.key}>
|
||||
<td>
|
||||
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
|
||||
<ReadOnlyFormItemComponent type="employee" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{fields.map((field, index) => {
|
||||
const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
|
||||
|
||||
return (
|
||||
<tr key={field.key}>
|
||||
<td>
|
||||
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
|
||||
<ReadOnlyFormItemComponent type="employee" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>{getPayoutMethodLabel(payoutMethod, t)}</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
|
||||
<ReadOnlyFormItemComponent type="currency" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
|
||||
<ReadOnlyFormItemComponent type="currency" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Form } from "antd";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TimeTicketTaskModalComponent } from "./time-ticket-task-modal.component.jsx";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key, values = {}) => {
|
||||
const translations = {
|
||||
"timetickets.fields.ro_number": "RO Number",
|
||||
"timetickets.labels.task": "Task",
|
||||
"bodyshop.fields.md_tasks_presets.percent": "Percent",
|
||||
"bodyshop.fields.md_tasks_presets.hourstype": "Labor Types",
|
||||
"bodyshop.fields.md_tasks_presets.nextstatus": "Next Status",
|
||||
"timetickets.labels.claimtaskpreview": "Claim Task Preview",
|
||||
"timetickets.fields.employee": "Employee",
|
||||
"timetickets.fields.cost_center": "Cost Center",
|
||||
"timetickets.fields.ciecacode": "Labor Type",
|
||||
"timetickets.fields.productivehrs": "Hours",
|
||||
"timetickets.fields.payout_method": "Payout Method",
|
||||
"timetickets.fields.rate": "Rate",
|
||||
"timetickets.fields.amount": "Amount",
|
||||
"timetickets.labels.payout_methods.commission": "Commission",
|
||||
"timetickets.labels.payout_methods.hourly": "Hourly",
|
||||
"timetickets.labels.payrollclaimedtasks": "Payroll claimed tasks are ready.",
|
||||
"tt_approvals.labels.approval_queue_in_use": "Approval queue is enabled."
|
||||
};
|
||||
|
||||
if (key === "timetickets.validation.unassignedlines") {
|
||||
return `${values.unassignedHours} hours remain unassigned.`;
|
||||
}
|
||||
|
||||
return translations[key] || key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("../form-items-formatted/read-only-form-item.component", () => ({
|
||||
default: ({ value }) => <span>{value}</span>
|
||||
}));
|
||||
|
||||
vi.mock("../job-search-select/job-search-select.component", () => ({
|
||||
default: () => <div>Job Search</div>
|
||||
}));
|
||||
|
||||
function TestHarness({ unassignedHours = 0 }) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{
|
||||
task: "Body Prep",
|
||||
timetickets: [
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
cost_center: "Body",
|
||||
ciecacode: "LAA",
|
||||
productivehrs: 2,
|
||||
rate: 40,
|
||||
payoutamount: 80,
|
||||
payout_context: {
|
||||
payout_method: "commission"
|
||||
}
|
||||
},
|
||||
{
|
||||
employeeid: "emp-2",
|
||||
cost_center: "Refinish",
|
||||
ciecacode: "LAB",
|
||||
productivehrs: 1,
|
||||
rate: 28,
|
||||
payoutamount: 28,
|
||||
payout_context: {
|
||||
payout_method: "hourly"
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
>
|
||||
<TimeTicketTaskModalComponent
|
||||
bodyshop={{
|
||||
md_tasks_presets: {
|
||||
presets: [
|
||||
{
|
||||
name: "Body Prep",
|
||||
percent: 50,
|
||||
hourstype: ["LAA", "LAB"],
|
||||
nextstatus: "In Progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
form={form}
|
||||
loading={false}
|
||||
completedTasks={[]}
|
||||
unassignedHours={unassignedHours}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TimeTicketTaskModalComponent", () => {
|
||||
it("shows preview payout methods for both commission and hourly tickets", () => {
|
||||
render(<TestHarness />);
|
||||
|
||||
expect(screen.getByText("Claim Task Preview")).toBeInTheDocument();
|
||||
expect(screen.getByText("Commission")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hourly")).toBeInTheDocument();
|
||||
expect(screen.getByText("Payroll claimed tasks are ready.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the unassigned-hours alert when payroll assignments are incomplete", () => {
|
||||
render(<TestHarness unassignedHours={1.25} />);
|
||||
|
||||
expect(screen.getByText("1.25 hours remain unassigned.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const getPreviewPayoutAmount = (ticket) => {
|
||||
const productiveHours = toFiniteNumber(ticket?.productivehrs);
|
||||
const rate = toFiniteNumber(ticket?.rate);
|
||||
|
||||
if (productiveHours === null || rate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return productiveHours * rate;
|
||||
};
|
||||
|
||||
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
|
||||
const [form] = Form.useForm();
|
||||
const { context, open, actions } = timeTicketTasksModal;
|
||||
@@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
|
||||
if (actions?.refetch) actions.refetch();
|
||||
toggleModalVisible();
|
||||
} else if (handleFinish === false) {
|
||||
form.setFieldsValue({ timetickets: data.ticketsToInsert });
|
||||
form.setFieldsValue({
|
||||
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
|
||||
...ticket,
|
||||
payoutamount: getPreviewPayoutAmount(ticket)
|
||||
}))
|
||||
});
|
||||
setUnassignedHours(data.unassignedHours);
|
||||
} else {
|
||||
notification.error({
|
||||
@@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: t("timetickets.errors.creating", { message: error.message })
|
||||
title: t("timetickets.errors.creating", {
|
||||
message: error.response?.data?.error || error.message
|
||||
})
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
|
||||
key: "memo",
|
||||
sorter: (a, b) => alphaSort(a.memo, b.memo),
|
||||
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
|
||||
render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo)
|
||||
render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo)
|
||||
},
|
||||
{
|
||||
title: t("timetickets.fields.task_name"),
|
||||
dataIndex: "task_name",
|
||||
key: "task_name",
|
||||
sorter: (a, b) => alphaSort(a.task_name, b.task_name),
|
||||
sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order,
|
||||
render: (text, record) => record.task_name || ""
|
||||
},
|
||||
{
|
||||
title: t("timetickets.fields.clockon"),
|
||||
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
|
||||
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
|
||||
},
|
||||
{
|
||||
title: "Pay",
|
||||
title: t("timetickets.fields.pay"),
|
||||
dataIndex: "pay",
|
||||
key: "pay",
|
||||
render: (text, record) =>
|
||||
Dinero({ amount: Math.round(record.rate * 100) })
|
||||
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
|
||||
Dinero({ amount: Math.round((record.rate || 0) * 100) })
|
||||
.multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
|
||||
.toFormat("$0.00")
|
||||
}
|
||||
];
|
||||
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
|
||||
<ResponsiveTable
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center"]}
|
||||
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center", "task_name"]}
|
||||
rowKey="id"
|
||||
scroll={{
|
||||
x: true
|
||||
|
||||
@@ -18,7 +18,15 @@ const mapStateToProps = createStructuredSelector({
|
||||
authLevel: selectAuthLevel
|
||||
});
|
||||
|
||||
export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabled, authLevel }) {
|
||||
export function TtApproveButton({
|
||||
bodyshop,
|
||||
currentUser,
|
||||
selectedTickets,
|
||||
disabled,
|
||||
authLevel,
|
||||
completedCallback,
|
||||
refetch
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const client = useApolloClient();
|
||||
const notification = useNotification();
|
||||
@@ -54,6 +62,12 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
|
||||
})
|
||||
});
|
||||
} else {
|
||||
if (typeof completedCallback === "function") {
|
||||
completedCallback([]);
|
||||
}
|
||||
if (typeof refetch === "function") {
|
||||
refetch();
|
||||
}
|
||||
notification.success({
|
||||
title: t("timetickets.successes.created")
|
||||
});
|
||||
@@ -68,8 +82,6 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// if (!!completedCallback) completedCallback([]);
|
||||
// if (!!loadingCallback) loadingCallback(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -91,6 +91,10 @@ export const QUERY_PARTS_BILLS_BY_JOBID = gql`
|
||||
order_number
|
||||
comments
|
||||
user_email
|
||||
bill {
|
||||
id
|
||||
invoice_number
|
||||
}
|
||||
}
|
||||
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
|
||||
id
|
||||
|
||||
@@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql`
|
||||
id
|
||||
employeeid
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql`
|
||||
id
|
||||
employeeid
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql`
|
||||
id
|
||||
employeeid
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
||||
employeeid
|
||||
id
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
||||
employeeid
|
||||
id
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
||||
employeeid
|
||||
id
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql`
|
||||
employeeid
|
||||
id
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
@@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
|
||||
employeeid
|
||||
id
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
percentage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,6 +1375,9 @@ export const QUERY_JOB_FOR_DUPE = gql`
|
||||
agt_ph2x
|
||||
area_of_damage
|
||||
cat_no
|
||||
cieca_pfl
|
||||
cieca_pfo
|
||||
cieca_pft
|
||||
cieca_stl
|
||||
cieca_ttl
|
||||
clm_addr1
|
||||
@@ -1452,6 +1455,7 @@ export const QUERY_JOB_FOR_DUPE = gql`
|
||||
labor_rate_desc
|
||||
labor_rate_id
|
||||
local_tax_rate
|
||||
materials
|
||||
other_amount_payable
|
||||
owner_owing
|
||||
ownerid
|
||||
|
||||
@@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
|
||||
id
|
||||
clockon
|
||||
clockoff
|
||||
created_by
|
||||
employeeid
|
||||
productivehrs
|
||||
actualhrs
|
||||
@@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
|
||||
date
|
||||
memo
|
||||
flat_rate
|
||||
task_name
|
||||
payout_context
|
||||
ttapprovalqueueid
|
||||
commited_by
|
||||
committed_at
|
||||
}
|
||||
|
||||
@@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql`
|
||||
ciecacode
|
||||
cost_center
|
||||
date
|
||||
memo
|
||||
flat_rate
|
||||
clockon
|
||||
clockoff
|
||||
rate
|
||||
created_by
|
||||
task_name
|
||||
payout_context
|
||||
}
|
||||
tt_approval_queue_aggregate {
|
||||
aggregate {
|
||||
@@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql`
|
||||
productivehrs
|
||||
actualhrs
|
||||
ciecacode
|
||||
cost_center
|
||||
date
|
||||
memo
|
||||
flat_rate
|
||||
rate
|
||||
clockon
|
||||
clockoff
|
||||
created_by
|
||||
task_name
|
||||
payout_context
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql`
|
||||
ciecacode
|
||||
bodyshopid
|
||||
cost_center
|
||||
clockon
|
||||
clockoff
|
||||
created_by
|
||||
task_name
|
||||
payout_context
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
@@ -29,7 +29,8 @@ import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-
|
||||
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -65,7 +66,41 @@ const DMS_SOCKET_EVENTS = {
|
||||
}
|
||||
};
|
||||
|
||||
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
|
||||
const stripRrXmlFromPayload = (input) => {
|
||||
if (input == null || typeof input !== "object") return input;
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = JSON.parse(JSON.stringify(input));
|
||||
} catch {
|
||||
// Fallback to in-place scrub if cloning fails.
|
||||
target = input;
|
||||
}
|
||||
|
||||
const scrub = (node) => {
|
||||
if (node == null || typeof node !== "object") return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(scrub);
|
||||
return;
|
||||
}
|
||||
|
||||
delete node.requestXml;
|
||||
delete node.responseXml;
|
||||
|
||||
if (node.xml && typeof node.xml === "object") {
|
||||
delete node.xml.request;
|
||||
delete node.xml.response;
|
||||
if (Object.keys(node.xml).length === 0) delete node.xml;
|
||||
}
|
||||
|
||||
Object.values(node).forEach(scrub);
|
||||
};
|
||||
|
||||
scrub(target);
|
||||
return target;
|
||||
};
|
||||
|
||||
export function DmsContainer({ bodyshop, currentUser, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
|
||||
const {
|
||||
treatments: { Fortellis }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -79,6 +114,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
const [allocationsSummary, setAllocationsSummary] = useState(null);
|
||||
const [reconnectNonce, setReconnectNonce] = useState(0);
|
||||
|
||||
const isDevEnv = import.meta.env.DEV;
|
||||
const isProdEnv = import.meta.env.PROD;
|
||||
const userEmail = (currentUser?.email || "").toLowerCase();
|
||||
|
||||
const devEmails = ["imex.dev", "rome.dev"];
|
||||
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
|
||||
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
|
||||
const canViewSensitiveRrXml = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
|
||||
|
||||
// Compute a single normalized mode and pick the proper socket
|
||||
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
|
||||
|
||||
@@ -164,19 +208,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
const providerLabel = useMemo(
|
||||
() =>
|
||||
({
|
||||
[DMS_MAP.reynolds]: "Reynolds",
|
||||
[DMS_MAP.fortellis]: "Fortellis",
|
||||
[DMS_MAP.cdk]: "CDK",
|
||||
[DMS_MAP.pbs]: "PBS"
|
||||
})[mode] || "DMS",
|
||||
[mode]
|
||||
[DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"),
|
||||
[DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"),
|
||||
[DMS_MAP.cdk]: t("dms.labels.provider_cdk"),
|
||||
[DMS_MAP.pbs]: t("dms.labels.provider_pbs")
|
||||
})[mode] || t("dms.labels.provider_dms"),
|
||||
[mode, t]
|
||||
);
|
||||
|
||||
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
|
||||
const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws");
|
||||
|
||||
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
|
||||
isConnected ? "Connected" : "Disconnected"
|
||||
}`;
|
||||
const bannerMessage = t("dms.labels.banner_message", {
|
||||
provider: providerLabel,
|
||||
transport: transportLabel,
|
||||
status: isConnected ? t("dms.labels.banner_status_connected") : t("dms.labels.banner_status_disconnected")
|
||||
});
|
||||
|
||||
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
|
||||
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
|
||||
@@ -239,6 +285,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
}, [jobId, mode, activeSocket]);
|
||||
|
||||
const handleExportFailed = (payload = {}) => {
|
||||
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
|
||||
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
|
||||
|
||||
const msg =
|
||||
@@ -246,7 +293,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
errText ||
|
||||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
|
||||
|
||||
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
|
||||
const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms"));
|
||||
|
||||
const isRrOpenRoLimit =
|
||||
isRrMode &&
|
||||
@@ -269,7 +316,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
timestamp: new Date(),
|
||||
level: (sev || "error").toUpperCase(),
|
||||
message: `${vendorTitle}: ${msg}`,
|
||||
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
|
||||
meta: { errorCode, vendorStatusCode, raw: safePayload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
|
||||
}
|
||||
]);
|
||||
};
|
||||
@@ -321,7 +368,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
{
|
||||
timestamp: new Date(),
|
||||
level: "warn",
|
||||
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
|
||||
message: t("dms.labels.reconnected_export_service", {
|
||||
provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel
|
||||
})
|
||||
}
|
||||
]);
|
||||
};
|
||||
@@ -340,11 +389,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
// Logs
|
||||
const onLog = isRrMode
|
||||
? (payload = {}) => {
|
||||
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
|
||||
const normalized = {
|
||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
|
||||
level: (payload.level || "INFO").toUpperCase(),
|
||||
message: payload.message || payload.msg || "",
|
||||
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
|
||||
timestamp: safePayload.timestamp
|
||||
? new Date(safePayload.timestamp)
|
||||
: safePayload.ts
|
||||
? new Date(safePayload.ts)
|
||||
: new Date(),
|
||||
level: (safePayload.level || "INFO").toUpperCase(),
|
||||
message: safePayload.message || safePayload.msg || "",
|
||||
meta: safePayload.meta ?? safePayload.ctx ?? safePayload.details ?? null
|
||||
};
|
||||
setLogs((prev) => [...prev, normalized]);
|
||||
}
|
||||
@@ -380,14 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
{
|
||||
timestamp: new Date(),
|
||||
level: "INFO",
|
||||
message:
|
||||
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
|
||||
message: t("dms.labels.rr_validation_message")
|
||||
}
|
||||
]);
|
||||
notification.info({
|
||||
title: "Reynolds RO created",
|
||||
description:
|
||||
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
|
||||
title: t("dms.labels.rr_validation_notice_title"),
|
||||
description: t("dms.labels.rr_validation_notice_description"),
|
||||
duration: 8
|
||||
});
|
||||
};
|
||||
@@ -399,8 +451,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
{
|
||||
timestamp: new Date(),
|
||||
level: "INFO",
|
||||
message:
|
||||
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
|
||||
message: t("dms.labels.rr_validation_message"),
|
||||
meta: { payload }
|
||||
}
|
||||
]);
|
||||
@@ -428,7 +479,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
activeSocket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
|
||||
}, [
|
||||
mode,
|
||||
activeSocket,
|
||||
channels,
|
||||
logLevel,
|
||||
notification,
|
||||
t,
|
||||
insertAuditTrail,
|
||||
history,
|
||||
isRrMode,
|
||||
providerLabel,
|
||||
canViewSensitiveRrXml
|
||||
]);
|
||||
|
||||
// RR finalize callback (unchanged public behavior)
|
||||
const handleRrValidationFinished = () => {
|
||||
@@ -471,7 +534,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col md={24} lg={10} className="dms-equal-height-col">
|
||||
<Col xs={24} xxl={10} className="dms-equal-height-col dms-top-panel-col">
|
||||
{!isRrMode ? (
|
||||
<DmsAllocationsSummary
|
||||
key={resetKey}
|
||||
@@ -511,7 +574,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col md={24} lg={14} className="dms-equal-height-col">
|
||||
<Col xs={24} xxl={14} className="dms-equal-height-col dms-top-panel-col">
|
||||
<DmsPostForm
|
||||
key={resetKey}
|
||||
socket={activeSocket}
|
||||
@@ -550,15 +613,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
<Switch
|
||||
checked={colorizeJson}
|
||||
onChange={setColorizeJson}
|
||||
checkedChildren="Color JSON"
|
||||
unCheckedChildren="Plain JSON"
|
||||
checkedChildren={t("dms.labels.color_json")}
|
||||
unCheckedChildren={t("dms.labels.plain_json")}
|
||||
/>
|
||||
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
|
||||
<Button onClick={toggleDetailsAll}>
|
||||
{detailsOpen ? t("dms.labels.collapse_all") : t("dms.labels.expand_all")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Select
|
||||
placeholder="Log Level"
|
||||
placeholder={t("dms.labels.log_level")}
|
||||
value={logLevel}
|
||||
onChange={(value) => {
|
||||
setLogLevel(value);
|
||||
@@ -572,8 +637,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
{ key: "ERROR", value: "ERROR", label: "ERROR" }
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
||||
<Button onClick={handleReconnectClick}>Reconnect</Button>
|
||||
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
|
||||
<Button onClick={handleReconnectClick}> {t("dms.labels.reconnect")}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -585,6 +650,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
detailsNonce={detailsNonce}
|
||||
colorizeJson={isRrMode ? colorizeJson : false}
|
||||
showDetails={isRrMode}
|
||||
allowXmlPayload={canViewSensitiveRrXml}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -142,13 +142,13 @@ export function ExportLogsPageComponent() {
|
||||
<div>
|
||||
<ul>
|
||||
{message.map((m, idx) => (
|
||||
<li key={idx}>{m}</li>
|
||||
<li key={idx}>{typeof m === "object" ? JSON.stringify(m) : m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div>{record.message}</div>;
|
||||
return <div>{typeof message === "object" ? JSON.stringify(message) : message}</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,12 @@ import JobsCreateOwnerInfoContainer from "../../components/jobs-create-owner-inf
|
||||
import JobsCreateVehicleInfoContainer from "../../components/jobs-create-vehicle-info/jobs-create-vehicle-info.container";
|
||||
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
|
||||
|
||||
export default function JobsCreateComponent({ form }) {
|
||||
export default function JobsCreateComponent({ form, isSubmitting }) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const [state] = useContext(JobCreateContext);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t("jobs.labels.create.vehicleinfo"),
|
||||
@@ -42,11 +40,9 @@ export default function JobsCreateComponent({ form }) {
|
||||
|
||||
const next = () => {
|
||||
setPageIndex(pageIndex + 1);
|
||||
console.log("Next");
|
||||
};
|
||||
const prev = () => {
|
||||
setPageIndex(pageIndex - 1);
|
||||
console.log("Previous");
|
||||
};
|
||||
|
||||
const ProgressButtons = ({ top }) => {
|
||||
@@ -79,17 +75,19 @@ export default function JobsCreateComponent({ form }) {
|
||||
{pageIndex === steps.length - 1 && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
onClick={() => {
|
||||
form
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
// NO OP
|
||||
form.submit();
|
||||
})
|
||||
.catch((error) => console.log("error", error));
|
||||
form.submit();
|
||||
.catch((error) => {
|
||||
console.log("error", error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t("general.actions.done")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
@@ -146,13 +144,11 @@ export default function JobsCreateComponent({ form }) {
|
||||
) : (
|
||||
<div>
|
||||
<ProgressButtons top />
|
||||
|
||||
{errorMessage ? (
|
||||
<div>
|
||||
<AlertComponent title={errorMessage} type="error" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{steps.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -46,6 +46,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
const [state, setState] = contextState;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [insertJob] = useMutation(INSERT_NEW_JOB);
|
||||
const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
|
||||
|
||||
@@ -83,16 +84,19 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
||||
newJobId: resp.data.insert_jobs.returning[0].id
|
||||
});
|
||||
logImEXEvent("manual_job_create_completed", {});
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
notification.error({
|
||||
title: t("jobs.errors.creating", { error: error })
|
||||
});
|
||||
setState({ ...state, error: error });
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFinish = (values) => {
|
||||
setIsSubmitting(true);
|
||||
let job = Object.assign(
|
||||
{},
|
||||
values,
|
||||
@@ -297,7 +301,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
||||
})
|
||||
}}
|
||||
>
|
||||
<JobsCreateComponent form={form} />
|
||||
<JobsCreateComponent form={form} isSubmitting={isSubmitting} />
|
||||
</Form>
|
||||
</RbacWrapper>
|
||||
</JobCreateContext.Provider>
|
||||
|
||||
@@ -231,13 +231,16 @@
|
||||
"overall": "Overall"
|
||||
},
|
||||
"disclaimer_title": "AI Scan Beta Disclaimer",
|
||||
"feedback_placeholder": "Tell us what worked, what didn't, and what could be better.",
|
||||
"feedback_prompt": "Was this AI scan helpful?",
|
||||
"generic_failure": "Failed to process invoice.",
|
||||
"multipage": "The is a multi-page document. Processing will take a few moments.",
|
||||
"processing": "Analyzing Bill",
|
||||
"scan": "AI Bill Scanner",
|
||||
"scancomplete": "AI Scan Complete",
|
||||
"scanfailed": "AI Scan Failed",
|
||||
"scanstarted": "AI Scan Started"
|
||||
"scanstarted": "AI Scan Started",
|
||||
"submit_feedback": "Submit Feedback"
|
||||
},
|
||||
"bill_lines": "Bill Lines",
|
||||
"bill_total": "Bill Total Amount",
|
||||
@@ -305,7 +308,8 @@
|
||||
"creatingdefaultview": "Error creating default view.",
|
||||
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
|
||||
"loading": "Unable to load shop details. Please call technical support.",
|
||||
"saving": "Error encountered while saving. {{message}}"
|
||||
"saving": "Error encountered while saving. {{message}}",
|
||||
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
|
||||
@@ -519,6 +523,7 @@
|
||||
"list-active": "Jobs -> List Active",
|
||||
"list-all": "Jobs -> List All",
|
||||
"list-ready": "Jobs -> List Ready",
|
||||
"manual-line": "Jobs -> Manual Line",
|
||||
"partsqueue": "Jobs -> Parts Queue",
|
||||
"void": "Jobs -> Void"
|
||||
},
|
||||
@@ -1074,7 +1079,36 @@
|
||||
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": "Refresh to see DMS Allocations."
|
||||
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
|
||||
"banner_status_connected": "Connected",
|
||||
"banner_status_disconnected": "Disconnected",
|
||||
"clear_logs": "Clear Logs",
|
||||
"collapse_all": "Collapse All",
|
||||
"color_json": "Color JSON",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"copy_request": "Copy Request",
|
||||
"copy_response": "Copy Response",
|
||||
"details": "Details",
|
||||
"expand_all": "Expand All",
|
||||
"hide_details": "Hide details",
|
||||
"log_level": "Log Level",
|
||||
"plain_json": "Plain JSON",
|
||||
"provider_cdk": "CDK",
|
||||
"provider_dms": "DMS",
|
||||
"provider_fortellis": "Fortellis",
|
||||
"provider_pbs": "PBS",
|
||||
"provider_reynolds": "Reynolds",
|
||||
"reconnect": "Reconnect",
|
||||
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
|
||||
"refreshallocations": "Refresh to see DMS Allocations.",
|
||||
"request_xml": "Request XML",
|
||||
"response_xml": "Response XML",
|
||||
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
|
||||
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
|
||||
"rr_validation_notice_title": "Reynolds RO created",
|
||||
"transport_ws": "(WS)",
|
||||
"transport_wss": "(WSS)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -1145,12 +1179,28 @@
|
||||
"new": "New Team",
|
||||
"newmember": "New Team Member"
|
||||
},
|
||||
"errors": {
|
||||
"allocation_total_exact": "Team allocation must total exactly 100%.",
|
||||
"duplicate_member": "Each employee can only appear once per team.",
|
||||
"minimum_one_member": "Add at least one team member."
|
||||
},
|
||||
"fields": {
|
||||
"active": "Active",
|
||||
"allocation": "Allocation",
|
||||
"allocation_percentage": "Allocation %",
|
||||
"employeeid": "Employee",
|
||||
"max_load": "Max Load",
|
||||
"name": "Team Name",
|
||||
"payout_method": "Payout Method",
|
||||
"percentage": "Percent"
|
||||
},
|
||||
"labels": {
|
||||
"allocation_total": "Allocation Total: {{total}}%"
|
||||
},
|
||||
"options": {
|
||||
"commission": "Commission",
|
||||
"commission_percentage": "Commission %",
|
||||
"hourly": "Hourly"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
@@ -1266,6 +1316,7 @@
|
||||
"delete": "Delete",
|
||||
"deleteall": "Delete All",
|
||||
"deselectall": "Deselect All",
|
||||
"done": "Done",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"gotoadmin": "Go to Admin Panel",
|
||||
@@ -3343,8 +3394,10 @@
|
||||
"void_ros": "Void ROs",
|
||||
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
|
||||
"work_in_progress_jobs": "Work in Progress - Jobs",
|
||||
"work_in_progress_labour": "Work in Progress - Labor",
|
||||
"work_in_progress_payables": "Work in Progress - Payables"
|
||||
"work_in_progress_labour": "Work in Progress - Labor (Detail)",
|
||||
"work_in_progress_labour_summary": "Work in Progress - Labor (Summary)",
|
||||
"work_in_progress_payables": "Work in Progress - Payables (Detail)",
|
||||
"work_in_progress_payables_summary": "Work in Progress - Payables (Summary)"
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
@@ -3561,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "Actual Hours",
|
||||
"amount": "Amount",
|
||||
"ciecacode": "CIECA Code",
|
||||
"clockhours": "Clock Hours",
|
||||
"clockoff": "Clock Off",
|
||||
@@ -3575,7 +3629,10 @@
|
||||
"employee_team": "Employee Team",
|
||||
"flat_rate": "Flat Rate?",
|
||||
"memo": "Memo",
|
||||
"pay": "Pay",
|
||||
"payout_method": "Payout Method",
|
||||
"productivehrs": "Productive Hours",
|
||||
"rate": "Rate",
|
||||
"ro_number": "Job to Post Against",
|
||||
"task_name": "Task"
|
||||
},
|
||||
@@ -3594,6 +3651,10 @@
|
||||
"lunch": "Lunch",
|
||||
"new": "New Time Ticket",
|
||||
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
|
||||
"payout_methods": {
|
||||
"commission": "Commission",
|
||||
"hourly": "Hourly"
|
||||
},
|
||||
"pmbreak": "PM Break",
|
||||
"pmshift": "PM Shift",
|
||||
"shift": "Shift",
|
||||
|
||||
@@ -231,13 +231,16 @@
|
||||
"overall": ""
|
||||
},
|
||||
"disclaimer_title": "",
|
||||
"feedback_placeholder": "",
|
||||
"feedback_prompt": "",
|
||||
"generic_failure": "",
|
||||
"multipage": "",
|
||||
"processing": "",
|
||||
"scan": "",
|
||||
"scancomplete": "",
|
||||
"scanfailed": "",
|
||||
"scanstarted": ""
|
||||
"scanstarted": "",
|
||||
"submit_feedback": ""
|
||||
},
|
||||
"bill_lines": "",
|
||||
"bill_total": "",
|
||||
@@ -305,7 +308,8 @@
|
||||
"creatingdefaultview": "",
|
||||
"duplicate_insurance_company": "",
|
||||
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
|
||||
"saving": ""
|
||||
"saving": "",
|
||||
"task_preset_allocation_exceeded": ""
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
@@ -519,6 +523,7 @@
|
||||
"list-active": "",
|
||||
"list-all": "",
|
||||
"list-ready": "",
|
||||
"manual-line": "",
|
||||
"partsqueue": "",
|
||||
"void": ""
|
||||
},
|
||||
@@ -1074,7 +1079,36 @@
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": ""
|
||||
"banner_message": "",
|
||||
"banner_status_connected": "",
|
||||
"banner_status_disconnected": "",
|
||||
"clear_logs": "",
|
||||
"collapse_all": "",
|
||||
"color_json": "",
|
||||
"copied": "",
|
||||
"copy": "",
|
||||
"copy_request": "",
|
||||
"copy_response": "",
|
||||
"details": "",
|
||||
"expand_all": "",
|
||||
"hide_details": "",
|
||||
"log_level": "",
|
||||
"plain_json": "",
|
||||
"provider_cdk": "",
|
||||
"provider_dms": "",
|
||||
"provider_fortellis": "",
|
||||
"provider_pbs": "",
|
||||
"provider_reynolds": "",
|
||||
"reconnect": "",
|
||||
"reconnected_export_service": "",
|
||||
"refreshallocations": "",
|
||||
"request_xml": "",
|
||||
"response_xml": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"transport_ws": "",
|
||||
"transport_wss": ""
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -1145,12 +1179,28 @@
|
||||
"new": "",
|
||||
"newmember": ""
|
||||
},
|
||||
"errors": {
|
||||
"allocation_total_exact": "",
|
||||
"duplicate_member": "",
|
||||
"minimum_one_member": ""
|
||||
},
|
||||
"fields": {
|
||||
"active": "",
|
||||
"allocation": "",
|
||||
"allocation_percentage": "",
|
||||
"employeeid": "",
|
||||
"max_load": "",
|
||||
"name": "",
|
||||
"payout_method": "",
|
||||
"percentage": ""
|
||||
},
|
||||
"labels": {
|
||||
"allocation_total": ""
|
||||
},
|
||||
"options": {
|
||||
"commission": "",
|
||||
"commission_percentage": "",
|
||||
"hourly": ""
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
@@ -1266,6 +1316,7 @@
|
||||
"delete": "Borrar",
|
||||
"deleteall": "",
|
||||
"deselectall": "",
|
||||
"done": "",
|
||||
"download": "",
|
||||
"edit": "Editar",
|
||||
"gotoadmin": "",
|
||||
@@ -3344,7 +3395,9 @@
|
||||
"work_in_progress_committed_labour": "",
|
||||
"work_in_progress_jobs": "",
|
||||
"work_in_progress_labour": "",
|
||||
"work_in_progress_payables": ""
|
||||
"work_in_progress_labour_summary": "",
|
||||
"work_in_progress_payables": "",
|
||||
"work_in_progress_payables_summary": ""
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
@@ -3561,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "",
|
||||
"amount": "",
|
||||
"ciecacode": "",
|
||||
"clockhours": "",
|
||||
"clockoff": "",
|
||||
@@ -3575,7 +3629,10 @@
|
||||
"employee_team": "",
|
||||
"flat_rate": "",
|
||||
"memo": "",
|
||||
"pay": "",
|
||||
"payout_method": "",
|
||||
"productivehrs": "",
|
||||
"rate": "",
|
||||
"ro_number": "",
|
||||
"task_name": ""
|
||||
},
|
||||
@@ -3594,6 +3651,10 @@
|
||||
"lunch": "",
|
||||
"new": "",
|
||||
"payrollclaimedtasks": "",
|
||||
"payout_methods": {
|
||||
"commission": "",
|
||||
"hourly": ""
|
||||
},
|
||||
"pmbreak": "",
|
||||
"pmshift": "",
|
||||
"shift": "",
|
||||
|
||||
@@ -231,13 +231,16 @@
|
||||
"overall": ""
|
||||
},
|
||||
"disclaimer_title": "",
|
||||
"feedback_placeholder": "",
|
||||
"feedback_prompt": "",
|
||||
"generic_failure": "",
|
||||
"multipage": "",
|
||||
"processing": "",
|
||||
"scan": "",
|
||||
"scancomplete": "",
|
||||
"scanfailed": "",
|
||||
"scanstarted": ""
|
||||
"scanstarted": "",
|
||||
"submit_feedback": ""
|
||||
},
|
||||
"bill_lines": "",
|
||||
"bill_total": "",
|
||||
@@ -305,7 +308,8 @@
|
||||
"creatingdefaultview": "",
|
||||
"duplicate_insurance_company": "",
|
||||
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
|
||||
"saving": ""
|
||||
"saving": "",
|
||||
"task_preset_allocation_exceeded": ""
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
@@ -519,6 +523,7 @@
|
||||
"list-active": "",
|
||||
"list-all": "",
|
||||
"list-ready": "",
|
||||
"manual-line": "",
|
||||
"partsqueue": "",
|
||||
"void": ""
|
||||
},
|
||||
@@ -1074,7 +1079,36 @@
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": ""
|
||||
"banner_message": "",
|
||||
"banner_status_connected": "",
|
||||
"banner_status_disconnected": "",
|
||||
"clear_logs": "",
|
||||
"collapse_all": "",
|
||||
"color_json": "",
|
||||
"copied": "",
|
||||
"copy": "",
|
||||
"copy_request": "",
|
||||
"copy_response": "",
|
||||
"details": "",
|
||||
"expand_all": "",
|
||||
"hide_details": "",
|
||||
"log_level": "",
|
||||
"plain_json": "",
|
||||
"provider_cdk": "",
|
||||
"provider_dms": "",
|
||||
"provider_fortellis": "",
|
||||
"provider_pbs": "",
|
||||
"provider_reynolds": "",
|
||||
"reconnect": "",
|
||||
"reconnected_export_service": "",
|
||||
"refreshallocations": "",
|
||||
"request_xml": "",
|
||||
"response_xml": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"transport_ws": "",
|
||||
"transport_wss": ""
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -1145,12 +1179,28 @@
|
||||
"new": "",
|
||||
"newmember": ""
|
||||
},
|
||||
"errors": {
|
||||
"allocation_total_exact": "",
|
||||
"duplicate_member": "",
|
||||
"minimum_one_member": ""
|
||||
},
|
||||
"fields": {
|
||||
"active": "",
|
||||
"allocation": "",
|
||||
"allocation_percentage": "",
|
||||
"employeeid": "",
|
||||
"max_load": "",
|
||||
"name": "",
|
||||
"payout_method": "",
|
||||
"percentage": ""
|
||||
},
|
||||
"labels": {
|
||||
"allocation_total": ""
|
||||
},
|
||||
"options": {
|
||||
"commission": "",
|
||||
"commission_percentage": "",
|
||||
"hourly": ""
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
@@ -1266,6 +1316,7 @@
|
||||
"delete": "Effacer",
|
||||
"deleteall": "",
|
||||
"deselectall": "",
|
||||
"done": "",
|
||||
"download": "",
|
||||
"edit": "modifier",
|
||||
"gotoadmin": "",
|
||||
@@ -3344,7 +3395,9 @@
|
||||
"work_in_progress_committed_labour": "",
|
||||
"work_in_progress_jobs": "",
|
||||
"work_in_progress_labour": "",
|
||||
"work_in_progress_payables": ""
|
||||
"work_in_progress_labour_summary": "",
|
||||
"work_in_progress_payables": "",
|
||||
"work_in_progress_payables_summary": ""
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
@@ -3561,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "",
|
||||
"amount": "",
|
||||
"ciecacode": "",
|
||||
"clockhours": "",
|
||||
"clockoff": "",
|
||||
@@ -3575,7 +3629,10 @@
|
||||
"employee_team": "",
|
||||
"flat_rate": "",
|
||||
"memo": "",
|
||||
"pay": "",
|
||||
"payout_method": "",
|
||||
"productivehrs": "",
|
||||
"rate": "",
|
||||
"ro_number": "",
|
||||
"task_name": ""
|
||||
},
|
||||
@@ -3594,6 +3651,10 @@
|
||||
"lunch": "",
|
||||
"new": "",
|
||||
"payrollclaimedtasks": "",
|
||||
"payout_methods": {
|
||||
"commission": "",
|
||||
"hourly": ""
|
||||
},
|
||||
"pmbreak": "",
|
||||
"pmshift": "",
|
||||
"shift": "",
|
||||
|
||||
@@ -1717,6 +1717,20 @@ export const TemplateList = (type, context) => {
|
||||
group: "jobs",
|
||||
featureNameRestricted: "timetickets"
|
||||
},
|
||||
work_in_progress_labour_summary: {
|
||||
title: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
|
||||
description: "",
|
||||
subject: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
|
||||
key: "work_in_progress_labour_summary",
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.jobs"),
|
||||
field: i18n.t("jobs.fields.date_open")
|
||||
},
|
||||
group: "jobs",
|
||||
featureNameRestricted: "timetickets"
|
||||
},
|
||||
work_in_progress_committed_labour: {
|
||||
title: i18n.t("reportcenter.templates.work_in_progress_committed_labour"),
|
||||
description: "",
|
||||
@@ -1746,6 +1760,20 @@ export const TemplateList = (type, context) => {
|
||||
group: "jobs",
|
||||
featureNameRestricted: "bills"
|
||||
},
|
||||
work_in_progress_payables_summary: {
|
||||
title: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
|
||||
description: "",
|
||||
subject: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
|
||||
key: "work_in_progress_payables_summary",
|
||||
//idtype: "vendor",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.jobs"),
|
||||
field: i18n.t("jobs.fields.date_open")
|
||||
},
|
||||
group: "jobs",
|
||||
featureNameRestricted: "bills"
|
||||
},
|
||||
lag_time: {
|
||||
title: i18n.t("reportcenter.templates.lag_time"),
|
||||
description: "",
|
||||
|
||||
140
client/tests/e2e/commission-based-cut.e2e.js
Normal file
140
client/tests/e2e/commission-based-cut.e2e.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { acceptEulaIfPresent, login } from "./utils/login";
|
||||
|
||||
async function openCommissionCutHarness(page) {
|
||||
await page.goto("/manage/_test?fixture=commission-cut");
|
||||
await acceptEulaIfPresent(page);
|
||||
await expect(page.getByRole("heading", { name: "Commission Cut Test Harness" })).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Commission-based cut", () => {
|
||||
test.skip(!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD, "Requires TEST_USERNAME and TEST_PASSWORD.");
|
||||
|
||||
test("renders payout previews and completes Pay All from the commission-cut harness", async ({ page }) => {
|
||||
let calculateLaborCalls = 0;
|
||||
let payAllCalls = 0;
|
||||
|
||||
await login(page, {
|
||||
email: process.env.TEST_USERNAME,
|
||||
password: process.env.TEST_PASSWORD
|
||||
});
|
||||
|
||||
await page.route("**/payroll/calculatelabor", async (route) => {
|
||||
calculateLaborCalls += 1;
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
expectedHours: 4,
|
||||
claimedHours: 1
|
||||
},
|
||||
{
|
||||
employeeid: "emp-2",
|
||||
mod_lbr_ty: "LAB",
|
||||
expectedHours: 2,
|
||||
claimedHours: 1
|
||||
}
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/payroll/payall", async (route) => {
|
||||
payAllCalls += 1;
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: "tt-1" }])
|
||||
});
|
||||
});
|
||||
|
||||
await openCommissionCutHarness(page);
|
||||
await expect(page.getByText("Claim Task Preview")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "Commission" })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "Hourly" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"There are currently 1.25 hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually."
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Pay All" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Pay All" }).click();
|
||||
|
||||
await expect.poll(() => calculateLaborCalls).toBeGreaterThan(0);
|
||||
await expect.poll(() => payAllCalls).toBe(1);
|
||||
await expect(page.getByText("All hours paid out successfully.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the backend error when Pay All is rejected", async ({ page }) => {
|
||||
await login(page, {
|
||||
email: process.env.TEST_USERNAME,
|
||||
password: process.env.TEST_PASSWORD
|
||||
});
|
||||
|
||||
await page.route("**/payroll/calculatelabor", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
expectedHours: 4,
|
||||
claimedHours: 1
|
||||
}
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/payroll/payall", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: "Not all hours have been assigned."
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await openCommissionCutHarness(page);
|
||||
await page.getByRole("button", { name: "Pay All" }).click();
|
||||
|
||||
await expect(page.getByText("Error flagging hours. Not all hours have been assigned.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows a negative labor difference when previously claimed hours exceed the current expected hours", async ({
|
||||
page
|
||||
}) => {
|
||||
await login(page, {
|
||||
email: process.env.TEST_USERNAME,
|
||||
password: process.env.TEST_PASSWORD
|
||||
});
|
||||
|
||||
await page.route("**/payroll/calculatelabor", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
employeeid: "emp-1",
|
||||
mod_lbr_ty: "LAA",
|
||||
expectedHours: 2,
|
||||
claimedHours: 5
|
||||
}
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
await openCommissionCutHarness(page);
|
||||
|
||||
await expect(page.locator("strong").filter({ hasText: "-3" }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,48 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
const formatToday = () => {
|
||||
const today = new Date();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const year = today.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
};
|
||||
|
||||
export async function acceptEulaIfPresent(page) {
|
||||
const eulaDialog = page.getByRole("dialog", { name: "Terms and Conditions" });
|
||||
|
||||
const eulaVisible =
|
||||
(await eulaDialog.isVisible().catch(() => false)) ||
|
||||
(await eulaDialog
|
||||
.waitFor({
|
||||
state: "visible",
|
||||
timeout: 5000
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false));
|
||||
|
||||
if (!eulaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markdownCard = page.locator(".eula-markdown-card");
|
||||
await markdownCard.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "First Name" }).fill("Codex");
|
||||
await page.getByRole("textbox", { name: "Last Name" }).fill("Tester");
|
||||
await page.getByRole("textbox", { name: "Legal Business Name" }).fill("Codex QA");
|
||||
await page.getByRole("textbox", { name: "Date Accepted" }).fill(formatToday());
|
||||
await page.getByRole("checkbox", { name: "I accept the terms and conditions of this agreement." }).check();
|
||||
|
||||
const acceptButton = page.getByRole("button", { name: "Accept EULA" });
|
||||
await expect(acceptButton).toBeEnabled({ timeout: 10000 });
|
||||
await acceptButton.click();
|
||||
await expect(eulaDialog).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
export async function login(page, { email, password }) {
|
||||
// Navigate to the login page
|
||||
await page.goto("/"); // Adjust if your login route differs (e.g., '/login')
|
||||
@@ -16,6 +59,8 @@ export async function login(page, { email, password }) {
|
||||
// Wait for navigation or success indicator (e.g., redirect to /manage/)
|
||||
await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect
|
||||
|
||||
await acceptEulaIfPresent(page);
|
||||
|
||||
// Verify successful login (e.g., check for a dashboard element)
|
||||
await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your app’s post-login UI
|
||||
}
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import { afterEach } from "vitest";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.IntersectionObserver) {
|
||||
window.IntersectionObserver = class IntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.scrollTo) {
|
||||
window.scrollTo = vi.fn();
|
||||
}
|
||||
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
}
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -173,12 +173,12 @@ export default defineConfig(({ command, mode }) => {
|
||||
open: true,
|
||||
proxy: {
|
||||
"/ws": {
|
||||
target: "ws://localhost:4000",
|
||||
target: "http://localhost:4000",
|
||||
secure: false,
|
||||
ws: true
|
||||
},
|
||||
"/wss": {
|
||||
target: "ws://localhost:4000",
|
||||
target: "http://localhost:4000",
|
||||
secure: false,
|
||||
ws: true
|
||||
},
|
||||
@@ -206,13 +206,13 @@ export default defineConfig(({ command, mode }) => {
|
||||
https: httpsCerts,
|
||||
proxy: {
|
||||
"/ws": {
|
||||
target: "ws://localhost:4000",
|
||||
target: "http://localhost:4000",
|
||||
rewriteWsOrigin: true,
|
||||
secure: false,
|
||||
ws: true
|
||||
},
|
||||
"/wss": {
|
||||
target: "ws://localhost:4000",
|
||||
target: "http://localhost:4000",
|
||||
rewriteWsOrigin: true,
|
||||
secure: false,
|
||||
ws: true
|
||||
|
||||
@@ -2156,10 +2156,12 @@
|
||||
- active:
|
||||
_eq: true
|
||||
columns:
|
||||
- commission_rates
|
||||
- created_at
|
||||
- employeeid
|
||||
- id
|
||||
- labor_rates
|
||||
- payout_method
|
||||
- percentage
|
||||
- teamid
|
||||
- updated_at
|
||||
@@ -2167,10 +2169,12 @@
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- commission_rates
|
||||
- created_at
|
||||
- employeeid
|
||||
- id
|
||||
- labor_rates
|
||||
- payout_method
|
||||
- percentage
|
||||
- teamid
|
||||
- updated_at
|
||||
@@ -2188,10 +2192,12 @@
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- commission_rates
|
||||
- created_at
|
||||
- employeeid
|
||||
- id
|
||||
- labor_rates
|
||||
- payout_method
|
||||
- percentage
|
||||
- teamid
|
||||
- updated_at
|
||||
@@ -6506,6 +6512,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- task_name
|
||||
@@ -6531,6 +6538,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- task_name
|
||||
@@ -6565,6 +6573,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- task_name
|
||||
@@ -6748,6 +6757,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- updated_at
|
||||
@@ -6768,6 +6778,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- updated_at
|
||||
@@ -6798,6 +6809,7 @@
|
||||
- id
|
||||
- jobid
|
||||
- memo
|
||||
- payout_context
|
||||
- productivehrs
|
||||
- rate
|
||||
- updated_at
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."employee_team_members" add column "payout_method" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."employee_team_members" add column "payout_method" text
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."employee_team_members" add column "commission_rates" jsonb
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."employee_team_members" add column "commission_rates" jsonb
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."timetickets" add column "payout_context" jsonb
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."timetickets" add column "payout_context" jsonb
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."tt_approval_queue" add column "payout_context" jsonb
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."tt_approval_queue" add column "payout_context" jsonb
|
||||
null;
|
||||
0
localstack/init/10-bootstrap.sh
Normal file → Executable file
0
localstack/init/10-bootstrap.sh
Normal file → Executable file
2373
package-lock.json
generated
2373
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
46
package.json
46
package.json
@@ -18,25 +18,25 @@
|
||||
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.997.0",
|
||||
"@aws-sdk/client-elasticache": "^3.997.0",
|
||||
"@aws-sdk/client-s3": "^3.997.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.997.0",
|
||||
"@aws-sdk/client-ses": "^3.997.0",
|
||||
"@aws-sdk/client-sqs": "^3.997.0",
|
||||
"@aws-sdk/client-textract": "^3.997.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.12",
|
||||
"@aws-sdk/lib-storage": "^3.997.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.997.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1014.0",
|
||||
"@aws-sdk/client-elasticache": "^3.1014.0",
|
||||
"@aws-sdk/client-s3": "^3.1014.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1014.0",
|
||||
"@aws-sdk/client-ses": "^3.1014.0",
|
||||
"@aws-sdk/client-sqs": "^3.1014.0",
|
||||
"@aws-sdk/client-textract": "^3.1014.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.24",
|
||||
"@aws-sdk/lib-storage": "^3.1014.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1014.0",
|
||||
"@opensearch-project/opensearch": "^2.13.0",
|
||||
"@socket.io/admin-ui": "^0.5.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4": "^1.13.2",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.13.6",
|
||||
"axios-curlirize": "^2.0.0",
|
||||
"better-queue": "^3.8.12",
|
||||
"bullmq": "^5.70.1",
|
||||
"bullmq": "^5.71.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"cloudinary": "^2.9.0",
|
||||
"compression": "^1.8.1",
|
||||
@@ -46,20 +46,20 @@
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.1",
|
||||
"fast-xml-parser": "^5.4.1",
|
||||
"firebase-admin": "^13.6.1",
|
||||
"fast-xml-parser": "^5.5.8",
|
||||
"firebase-admin": "^13.7.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphql": "^16.13.0",
|
||||
"graphql": "^16.13.1",
|
||||
"graphql-request": "^6.1.0",
|
||||
"intuit-oauth": "^4.2.2",
|
||||
"ioredis": "^5.9.3",
|
||||
"ioredis": "^5.10.1",
|
||||
"json-2-csv": "^5.5.10",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"juice": "^11.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"multer": "^2.0.2",
|
||||
"moment-timezone": "^0.6.1",
|
||||
"multer": "^2.1.1",
|
||||
"mustache": "^4.2.0",
|
||||
"node-persist": "^4.0.4",
|
||||
"nodemailer": "^6.10.0",
|
||||
@@ -69,15 +69,15 @@
|
||||
"recursive-diff": "^1.0.9",
|
||||
"rimraf": "^6.1.3",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"soap": "^1.7.1",
|
||||
"soap": "^1.8.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-adapter": "^2.5.6",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"twilio": "^5.12.2",
|
||||
"twilio": "^5.13.0",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-cloudwatch": "^6.3.0",
|
||||
"xml-formatter": "^3.6.7",
|
||||
"xml-formatter": "^3.7.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder2": "^4.0.3",
|
||||
"yazl": "^3.3.1"
|
||||
@@ -86,11 +86,11 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.3.0",
|
||||
"globals": "^17.4.0",
|
||||
"mock-require": "^3.0.3",
|
||||
"p-limit": "^3.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +280,8 @@ const connectToRedisCluster = async () => {
|
||||
redisCluster.on("node error", (error, node) => {
|
||||
console.dir(error);
|
||||
logger.log(`Redis node error`, "ERROR", "redis", "api", {
|
||||
host: node.options.host,
|
||||
port: node.options.port,
|
||||
host: node?.options?.host,
|
||||
port: node?.options?.port,
|
||||
message: error.message
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,12 +130,13 @@ exports.default = async (req, res) => {
|
||||
|
||||
async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
||||
try {
|
||||
const url = urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
|
||||
);
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
|
||||
),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -150,6 +151,11 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
||||
bodyshopid: bill.job.shopid,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-payables-query", "DEBUG", req.user.email, null, {
|
||||
method: "QueryVendorRecord",
|
||||
call: url,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
return result.json?.QueryResponse?.Vendor?.[0];
|
||||
} catch (error) {
|
||||
@@ -167,8 +173,9 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
||||
DisplayName: StandardizeName(bill.vendor.name)
|
||||
};
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "vendor");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "vendor"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -184,6 +191,12 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
||||
bodyshopid: bill.job.shopid,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
|
||||
method: "InsertVendorRecord",
|
||||
call: url,
|
||||
Vendor: Vendor,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
throw new Error(JSON.stringify(result.json.Fault));
|
||||
@@ -274,11 +287,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
||||
VendorRef: {
|
||||
value: vendor.Id
|
||||
},
|
||||
...(vendor.TermRef && !bill.is_credit_memo && {
|
||||
SalesTermRef: {
|
||||
value: vendor.TermRef.value
|
||||
}
|
||||
}),
|
||||
...(vendor.TermRef &&
|
||||
!bill.is_credit_memo && {
|
||||
SalesTermRef: {
|
||||
value: vendor.TermRef.value
|
||||
}
|
||||
}),
|
||||
TxnDate: moment(bill.date)
|
||||
//.tz(bill.job.bodyshop.timezone)
|
||||
.format("YYYY-MM-DD"),
|
||||
@@ -318,8 +332,9 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
||||
[logKey]: logValue
|
||||
});
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -335,6 +350,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
||||
bodyshopid: bill.job.shopid,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-payables-insert", "DEBUG", req.user.email, null, {
|
||||
method: "InsertBill",
|
||||
call: url,
|
||||
postingObj: bill.is_credit_memo ? VendorCredit : billQbo,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
throw new Error(JSON.stringify(result.json.Fault));
|
||||
|
||||
@@ -82,14 +82,7 @@ exports.default = async (req, res) => {
|
||||
|
||||
if (isThreeTier || (!isThreeTier && twoTierPref === "name")) {
|
||||
//Insert the name/owner and account for whether the source should be the ins co in 3 tier..
|
||||
ownerCustomerTier = await QueryOwner(
|
||||
oauthClient,
|
||||
qbo_realmId,
|
||||
req,
|
||||
payment.job,
|
||||
isThreeTier,
|
||||
insCoCustomerTier
|
||||
);
|
||||
ownerCustomerTier = await QueryOwner(oauthClient, qbo_realmId, req, payment.job, insCoCustomerTier);
|
||||
//Query for the owner itself.
|
||||
if (!ownerCustomerTier) {
|
||||
ownerCustomerTier = await InsertOwner(
|
||||
@@ -229,8 +222,9 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
|
||||
paymentQbo
|
||||
});
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "payment");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "payment"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -246,6 +240,12 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
|
||||
bodyshopid: payment.job.shopid,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
|
||||
method: "InsertPayment",
|
||||
call: url,
|
||||
paymentQbo: paymentQbo,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
throw new Error(JSON.stringify(result.json.Fault));
|
||||
@@ -428,8 +428,9 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
|
||||
paymentQbo
|
||||
});
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "creditmemo");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "creditmemo"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -445,6 +446,12 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
|
||||
bodyshopid: req.user.bodyshopid,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-metadata-query", "DEBUG", req.user.email, null, {
|
||||
method: "InsertCreditMemo",
|
||||
call: url,
|
||||
paymentQbo: paymentQbo,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
throw new Error(JSON.stringify(result.json.Fault));
|
||||
|
||||
@@ -213,12 +213,13 @@ exports.default = async (req, res) => {
|
||||
|
||||
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
|
||||
try {
|
||||
const url = urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
|
||||
);
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
|
||||
),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -233,6 +234,11 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
|
||||
method: "QueryInsuranceCo",
|
||||
call: url,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
return result.json?.QueryResponse?.Customer?.[0];
|
||||
} catch (error) {
|
||||
@@ -266,8 +272,9 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "customer");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "customer"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -283,6 +290,12 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
|
||||
method: "InsertInsuranceCo",
|
||||
call: url,
|
||||
customerObj: Customer,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
return result.json?.Customer;
|
||||
} catch (error) {
|
||||
@@ -298,12 +311,13 @@ exports.InsertInsuranceCo = InsertInsuranceCo;
|
||||
|
||||
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
const ownerName = generateOwnerTier(job, true, null);
|
||||
const url = urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
|
||||
);
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
|
||||
),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -318,6 +332,11 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
|
||||
method: "QueryOwner",
|
||||
call: url,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
return result.json?.QueryResponse?.Customer?.find((x) => x.ParentRef?.value === parentTierRef?.Id);
|
||||
}
|
||||
@@ -347,8 +366,9 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
||||
: {})
|
||||
};
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "customer");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "customer"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -364,6 +384,12 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
|
||||
method: "InsertOwner",
|
||||
call: url,
|
||||
customerObj: Customer,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
return result.json?.Customer;
|
||||
} catch (error) {
|
||||
@@ -378,12 +404,13 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
||||
exports.InsertOwner = InsertOwner;
|
||||
|
||||
async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
const url = urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
|
||||
);
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(
|
||||
qbo_realmId,
|
||||
"query",
|
||||
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
|
||||
),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -398,6 +425,11 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
|
||||
method: "QueryJob",
|
||||
call: url,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
const customers = result.json?.QueryResponse?.Customer;
|
||||
return customers && (parentTierRef ? customers.find((x) => x.ParentRef.value === parentTierRef.Id) : customers[0]);
|
||||
@@ -423,8 +455,9 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const url = urlBuilder(qbo_realmId, "customer");
|
||||
const result = await oauthClient.makeApiCall({
|
||||
url: urlBuilder(qbo_realmId, "customer"),
|
||||
url: url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -440,6 +473,12 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||
jobid: job.id,
|
||||
email: req.user.email
|
||||
});
|
||||
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
|
||||
method: "InsertJob",
|
||||
call: url,
|
||||
customerObj: Customer,
|
||||
result: result.json
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
throw new Error(JSON.stringify(result.json.Fault));
|
||||
|
||||
73
server/ai/bill-ai-feedback.js
Normal file
73
server/ai/bill-ai-feedback.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { isString } = require("lodash");
|
||||
const { sendServerEmail } = require("../email/sendemail");
|
||||
const logger = require("../utils/logger");
|
||||
const { raw } = require("express");
|
||||
|
||||
const SUPPORT_EMAIL = "patrick@imexsystems.ca";
|
||||
|
||||
const safeJsonParse = (maybeJson) => {
|
||||
if (!isString(maybeJson)) return null;
|
||||
try {
|
||||
return JSON.parse(maybeJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleBillAiFeedback = async (req, res) => {
|
||||
try {
|
||||
const rating = req.body?.rating;
|
||||
const comments = isString(req.body?.comments) ? req.body?.comments?.trim() : "";
|
||||
|
||||
const billFormValues = safeJsonParse(req.body?.billFormValues);
|
||||
const rawAIData = safeJsonParse(req.body?.rawAIData);
|
||||
|
||||
const jobid = billFormValues?.jobid || billFormValues?.jobId || "unknown";
|
||||
const shopname = req.body?.shopname || "unknown";
|
||||
const subject = `Bill AI Feedback (${rating === "up" ? "+" : "-"}) Shop=${shopname} jobid=${jobid}`;
|
||||
|
||||
const text = [
|
||||
`User: ${req?.user?.email || "unknown"}`,
|
||||
`Rating: ${rating}`,
|
||||
comments ? `Comments: ${comments}` : "Comments: (none)",
|
||||
"",
|
||||
"Form Values (User):",
|
||||
JSON.stringify(billFormValues, null, 4),
|
||||
"",
|
||||
"Raw AI Data:",
|
||||
JSON.stringify(rawAIData, null, 4)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const attachments = [];
|
||||
if (req.file?.buffer) {
|
||||
attachments.push({
|
||||
filename: req.file.originalname || `bill-${jobid}.pdf`,
|
||||
content: req.file.buffer,
|
||||
contentType: req.file.mimetype || "application/pdf"
|
||||
});
|
||||
}
|
||||
|
||||
await sendServerEmail({
|
||||
to: [SUPPORT_EMAIL],
|
||||
subject,
|
||||
type: "text",
|
||||
text,
|
||||
attachments
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.log("bill-ai-feedback-error", "ERROR", req?.user?.email, null, {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
return res.status(500).json({ message: "Failed to submit feedback" });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handleBillAiFeedback
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
|
||||
const Fuse = require('fuse.js');
|
||||
const { has } = require("lodash");
|
||||
|
||||
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
|
||||
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||
|
||||
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
|
||||
|
||||
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
|
||||
// Helper function to normalize fields
|
||||
const normalizePartNumber = (str) => {
|
||||
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
@@ -17,7 +17,38 @@ const normalizeText = (str) => {
|
||||
};
|
||||
const normalizePrice = (str) => {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str.replace(/[^0-9.-]+/g, "");
|
||||
|
||||
let value = str.trim();
|
||||
|
||||
// Handle European-style decimal comma like "292,37".
|
||||
// Only treat the *last* comma as a decimal separator when:
|
||||
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
|
||||
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
|
||||
if (!value.includes('.') && value.includes(',')) {
|
||||
const lastCommaIndex = value.lastIndexOf(',');
|
||||
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
|
||||
|
||||
if (/^\d{1,2}$/.test(decimalSuffix)) {
|
||||
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
|
||||
value = `${before}.${decimalSuffix}`;
|
||||
} else {
|
||||
// Treat commas as thousands separators (or noise) and drop them.
|
||||
value = value.replace(/,/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
return value.replace(/[^0-9.-]+/g, "");
|
||||
};
|
||||
|
||||
const roundToIncrement = (value, increment) => {
|
||||
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
|
||||
// Prevent float artifacts (e.g. 0.20500000000000002)
|
||||
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
|
||||
return parseFloat(rounded.toFixed(decimals));
|
||||
};
|
||||
|
||||
//More complex function. Not necessary at the moment, keeping for reference.
|
||||
@@ -134,6 +165,7 @@ const calculateTextractConfidence = (textractLineItem) => {
|
||||
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
|
||||
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
|
||||
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
|
||||
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
|
||||
|
||||
// Calculate weighted average, giving more weight to important fields
|
||||
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
|
||||
@@ -173,10 +205,11 @@ const calculateTextractConfidence = (textractLineItem) => {
|
||||
if (!hasActualCost) missingCount++;
|
||||
if (!hasActualPrice) missingCount++;
|
||||
if (!hasLineDesc) missingCount++;
|
||||
if (!hasQuantity) missingCount++;
|
||||
|
||||
// Each missing field reduces confidence by 15%
|
||||
// Each missing field reduces confidence by 20%
|
||||
if (missingCount > 0) {
|
||||
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
|
||||
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
|
||||
}
|
||||
|
||||
avgConfidence = avgConfidence * missingFieldsPenalty;
|
||||
@@ -361,16 +394,16 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
|
||||
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
|
||||
|
||||
const vendorFuse = new Fuse(
|
||||
jobData.vendors,
|
||||
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
|
||||
{
|
||||
keys: ['name'],
|
||||
threshold: 0.4, //Adjust as needed for matching sensitivity,
|
||||
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
},
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
const vendorMatches = vendorFuse.search(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value);
|
||||
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
|
||||
|
||||
let vendorid;
|
||||
if (vendorMatches.length > 0) {
|
||||
@@ -381,6 +414,21 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
|
||||
throw new Error('Job not found for bill form data generation.');
|
||||
}
|
||||
|
||||
|
||||
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
|
||||
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
|
||||
let discountPercentageDecimal = 0;
|
||||
if (subtotalDiscountValueRaw) {
|
||||
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
|
||||
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
|
||||
if (subtotal > 0 && subtotalDiscountValue) {
|
||||
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
|
||||
// but only allow half-percent increments (0.005 steps).
|
||||
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
|
||||
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: How do we handle freight lines and core charges?
|
||||
//Create the form data structure for the bill posting screen.
|
||||
const billFormData = {
|
||||
@@ -448,6 +496,31 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
|
||||
}
|
||||
}
|
||||
|
||||
//If there's nothing, just fall back to seeing if there's a price object from textract.
|
||||
|
||||
if (!actualPrice && textractLineItem.PRICE) {
|
||||
actualPrice = textractLineItem.PRICE.value;
|
||||
}
|
||||
if (!actualCost && textractLineItem.PRICE) {
|
||||
actualCost = textractLineItem.PRICE.value;
|
||||
}
|
||||
|
||||
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
|
||||
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
|
||||
if (quantity && quantity > 1) {
|
||||
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
|
||||
actualPrice = actualPrice / quantity;
|
||||
}
|
||||
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
|
||||
actualCost = actualCost / quantity;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (discountPercentageDecimal > 0) {
|
||||
actualCost = actualCost * (1 - discountPercentageDecimal);
|
||||
}
|
||||
|
||||
const responsibilityCenters = job.bodyshop.md_responsibility_centers
|
||||
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
|
||||
const lineObject = {
|
||||
@@ -714,5 +787,6 @@ const bodyshopHasDmsKey = (bodyshop) =>
|
||||
|
||||
|
||||
module.exports = {
|
||||
generateBillFormData
|
||||
generateBillFormData,
|
||||
normalizePrice
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ function normalizeLabelName(labelText) {
|
||||
'unit_price': standardizedFieldsnames.actual_price,
|
||||
'list': standardizedFieldsnames.actual_price,
|
||||
'retail_price': standardizedFieldsnames.actual_price,
|
||||
'retail': standardizedFieldsnames.actual_price,
|
||||
'net': standardizedFieldsnames.actual_cost,
|
||||
'selling_price': standardizedFieldsnames.actual_cost,
|
||||
'net_price': standardizedFieldsnames.actual_cost,
|
||||
'net_cost': standardizedFieldsnames.actual_cost,
|
||||
'total': standardizedFieldsnames.actual_cost,
|
||||
'po_no': standardizedFieldsnames.ro_number,
|
||||
'customer_po_no': standardizedFieldsnames.ro_number,
|
||||
'customer_po_no_': standardizedFieldsnames.ro_number
|
||||
|
||||
@@ -6,6 +6,7 @@ const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPa
|
||||
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
|
||||
const { generateBillFormData } = require("./bill-ocr-generator");
|
||||
const logger = require("../../utils/logger");
|
||||
const _ = require("lodash");
|
||||
|
||||
// Initialize AWS clients
|
||||
const awsConfig = {
|
||||
@@ -66,7 +67,7 @@ async function handleBillOcr(req, res) {
|
||||
if (fileType === 'image') {
|
||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -82,7 +83,7 @@ async function handleBillOcr(req, res) {
|
||||
// Process synchronously for single-page documents
|
||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
status: 'COMPLETED',
|
||||
@@ -211,7 +212,8 @@ async function processSinglePageDocument(pdfBuffer) {
|
||||
|
||||
return {
|
||||
...processedData,
|
||||
originalTextractResponse: result
|
||||
//Removed as this is a large object that provides minimal value to send to client.
|
||||
// originalTextractResponse: result
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,7 +393,8 @@ async function handleTextractNotification(message) {
|
||||
status: 'COMPLETED',
|
||||
data: {
|
||||
...processedData,
|
||||
originalTextractResponse: originalResponse
|
||||
//Removed as this is a large object that provides minimal value to send to client.
|
||||
// originalTextractResponse: originalResponse
|
||||
},
|
||||
completedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -66,7 +66,12 @@ exports.default = async function ReloadCdkMakes(req, res) {
|
||||
} catch (error) {
|
||||
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||
cdk_dealerid,
|
||||
error
|
||||
error: {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
code: error?.code
|
||||
}
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
@@ -105,7 +110,12 @@ async function GetCdkMakes(req, cdk_dealerid) {
|
||||
} catch (error) {
|
||||
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||
cdk_dealerid,
|
||||
error
|
||||
error: {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
code: error?.code
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error(error);
|
||||
@@ -141,7 +151,12 @@ async function GetFortellisMakes(req, cdk_dealerid) {
|
||||
} catch (error) {
|
||||
logger.log("fortellis-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||
cdk_dealerid,
|
||||
error
|
||||
error: {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
code: error?.code
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error(error);
|
||||
|
||||
@@ -250,6 +250,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
|
||||
},
|
||||
InsuranceCompany: job.ins_co_nm || "",
|
||||
Claim: job.clm_no || "",
|
||||
Deductible: job.ded_amt || 0,
|
||||
PolicyNo: job.policy_no || "",
|
||||
DMSAllocation: job.dms_allocation || "",
|
||||
Contacts: {
|
||||
CSR: job.employee_csr_rel
|
||||
|
||||
@@ -44,7 +44,7 @@ const logEmail = async (req, email) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendServerEmail = async ({ subject, text, to = [] }) => {
|
||||
const sendServerEmail = async ({ subject, text, to = [], attachments }) => {
|
||||
if (process.env.NODE_ENV === undefined) return;
|
||||
|
||||
try {
|
||||
@@ -57,6 +57,7 @@ const sendServerEmail = async ({ subject, text, to = [] }) => {
|
||||
to: ["support@imexsystems.ca", ...to],
|
||||
subject: subject,
|
||||
text: text,
|
||||
attachments: attachments,
|
||||
ses: {
|
||||
// optional extra arguments for SendRawEmail
|
||||
Tags: [
|
||||
|
||||
@@ -959,7 +959,7 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
|
||||
delete DMSVehToSend.inventoryAccount;
|
||||
|
||||
!DMSVehToSend.vehicle.engineNumber && delete DMSVehToSend.vehicle.engineNumber;
|
||||
!DMSVehToSend.vehicle.saleClassValue && DMSVehToSend.vehicle.saleClassValue === "MISC";
|
||||
!DMSVehToSend.vehicle.saleClassValue && (DMSVehToSend.vehicle.saleClassValue = "MISC");
|
||||
!DMSVehToSend.vehicle.exteriorColor && delete DMSVehToSend.vehicle.exteriorColor;
|
||||
|
||||
const result = await MakeFortellisCall({
|
||||
|
||||
@@ -1285,6 +1285,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
|
||||
date_repairstarted
|
||||
date_void
|
||||
dms_allocation
|
||||
ded_amt
|
||||
employee_body_rel {
|
||||
first_name
|
||||
last_name
|
||||
@@ -1380,6 +1381,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
|
||||
}
|
||||
parts_tax_rates
|
||||
plate_no
|
||||
policy_no
|
||||
rate_la1
|
||||
rate_la2
|
||||
rate_la3
|
||||
@@ -2461,6 +2463,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
}
|
||||
percentage
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2471,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
productivehrs
|
||||
actualhrs
|
||||
ciecacode
|
||||
payout_context
|
||||
}
|
||||
lbr_adjustments
|
||||
ro_number
|
||||
@@ -2562,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
}
|
||||
percentage
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2572,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
productivehrs
|
||||
actualhrs
|
||||
ciecacode
|
||||
payout_context
|
||||
}
|
||||
lbr_adjustments
|
||||
ro_number
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user