Compare commits
74 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e60dcd6b | ||
|
|
f904fa4e18 | ||
|
|
b5997d0b8f | ||
|
|
19f918b695 | ||
|
|
b5b56f12aa | ||
|
|
76b15f0521 | ||
|
|
27ffee0c7a | ||
|
|
03e06cfd96 | ||
|
|
0622696650 | ||
|
|
187938286d | ||
|
|
51fca7a63c | ||
|
|
6ad0272135 | ||
|
|
86db96f47d | ||
|
|
9d9e626cfe | ||
|
|
285043a6ba | ||
|
|
5812d53efc | ||
|
|
b2231007b6 | ||
|
|
fcc05156bc | ||
|
|
31c6e7c0e5 | ||
|
|
f0f5c09fd7 | ||
|
|
debc67cc49 | ||
|
|
281fe02820 | ||
|
|
34c9a3854c | ||
|
|
ac8c84543a | ||
|
|
78a2ff0fa1 | ||
|
|
98781a76e6 | ||
|
|
172bbecff7 | ||
|
|
3f03157834 | ||
|
|
dac1ed42df | ||
|
|
782fa8a1c7 | ||
|
|
45688c0dde | ||
|
|
aebd8da4ae | ||
|
|
b4e8a80735 | ||
|
|
73ba95a240 | ||
|
|
bc5f9f88d1 | ||
|
|
8af8c8039c | ||
|
|
3a1d10b0d1 | ||
|
|
e6071709be | ||
|
|
c95c11fd0e | ||
|
|
1351fbb814 | ||
|
|
dcd3a078ef | ||
|
|
bb8e140f6e | ||
|
|
bf11e10676 | ||
|
|
92e6bdf2a2 | ||
|
|
a02e336d73 | ||
|
|
7ec8a73c30 | ||
|
|
e669c19b98 | ||
|
|
5c55c0c74b | ||
|
|
f1f705903a | ||
|
|
6551be2d92 | ||
|
|
48e59fe849 | ||
|
|
7991192496 | ||
|
|
05cd60c2a1 | ||
|
|
26fc76a767 | ||
|
|
49816d5d43 | ||
|
|
b9b3e2c2aa | ||
|
|
e3c02f94f1 | ||
|
|
490dd662d5 | ||
|
|
8d00fc29d1 | ||
|
|
f04f48f593 | ||
|
|
721e9bc464 | ||
|
|
76c828a1c9 | ||
|
|
7e5363f911 | ||
|
|
f5b16394f9 | ||
|
|
7132465945 | ||
|
|
a873a2573a | ||
|
|
ff24db6561 | ||
|
|
da26954c3b | ||
|
|
6991cf60e5 | ||
|
|
818aedf04f | ||
|
|
1cb6834207 | ||
|
|
8577929bd4 | ||
|
|
f44121e06b | ||
|
|
faf9fb75c5 |
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { store } from "../../redux/store";
|
||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { TimeFormatter } from "../../utils/DateFormatter";
|
||||
import PhoneFormatter from "../../utils/PhoneFormatter";
|
||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
import { onlyUnique } from "../../utils/arrayHelper";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
|
||||
@@ -28,6 +28,7 @@ import ProductionListColumnCategory from "./production-list-columns.status.categ
|
||||
import ProductionListColumnStatus from "./production-list-columns.status.component";
|
||||
import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
import ChatOpenButton from "../chat-open-button/chat-open-button.component.jsx";
|
||||
|
||||
const getEmployeeName = (employeeId, employees) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
@@ -271,14 +272,24 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
||||
dataIndex: "ownr_ph1",
|
||||
key: "ownr_ph1",
|
||||
ellipsis: true,
|
||||
render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
|
||||
render: (text, record) =>
|
||||
technician ? (
|
||||
<PhoneNumberFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneNumberFormatter>
|
||||
) : (
|
||||
<ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.id} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: i18n.t("jobs.fields.ownr_ph2"),
|
||||
dataIndex: "ownr_ph2",
|
||||
key: "ownr_ph2",
|
||||
ellipsis: true,
|
||||
render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
|
||||
render: (text, record) =>
|
||||
technician ? (
|
||||
<PhoneNumberFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneNumberFormatter>
|
||||
) : (
|
||||
<ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.id} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: i18n.t("jobs.fields.specialcoveragepolicy"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -152,7 +152,7 @@ export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete
|
||||
{!isPartsEntry && (
|
||||
<>
|
||||
<Form.Item label={t("vendors.fields.discount")} name="discount">
|
||||
<InputNumber min={0} max={1} precision={2} step={0.01} />
|
||||
<InputNumber min={0} max={1} precision={3} step={0.01} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("vendors.fields.due_date")} name="due_date">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,36 +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.",
|
||||
"provider_reynolds": "Reynolds",
|
||||
"provider_fortellis": "Fortellis",
|
||||
"provider_cdk": "CDK",
|
||||
"provider_pbs": "PBS",
|
||||
"provider_dms": "DMS",
|
||||
"transport_wss": "(WSS)",
|
||||
"transport_ws": "(WS)",
|
||||
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
|
||||
"banner_status_connected": "Connected",
|
||||
"banner_status_disconnected": "Disconnected",
|
||||
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
|
||||
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
|
||||
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
|
||||
"rr_validation_notice_title": "Reynolds RO created",
|
||||
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
|
||||
"color_json": "Color JSON",
|
||||
"plain_json": "Plain JSON",
|
||||
"collapse_all": "Collapse All",
|
||||
"expand_all": "Expand All",
|
||||
"log_level": "Log Level",
|
||||
"clear_logs": "Clear Logs",
|
||||
"reconnect": "Reconnect",
|
||||
"details": "Details",
|
||||
"hide_details": "Hide details",
|
||||
"copy": "Copy",
|
||||
"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"
|
||||
"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": {
|
||||
@@ -1174,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": {
|
||||
@@ -3373,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": {
|
||||
@@ -3591,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "Actual Hours",
|
||||
"amount": "Amount",
|
||||
"ciecacode": "CIECA Code",
|
||||
"clockhours": "Clock Hours",
|
||||
"clockoff": "Clock Off",
|
||||
@@ -3605,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"
|
||||
},
|
||||
@@ -3624,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,36 +1079,36 @@
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": "",
|
||||
"provider_reynolds": "",
|
||||
"provider_fortellis": "",
|
||||
"provider_cdk": "",
|
||||
"provider_pbs": "",
|
||||
"provider_dms": "",
|
||||
"transport_wss": "",
|
||||
"transport_ws": "",
|
||||
"banner_message": "",
|
||||
"banner_status_connected": "",
|
||||
"banner_status_disconnected": "",
|
||||
"banner_message": "",
|
||||
"reconnected_export_service": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"color_json": "",
|
||||
"plain_json": "",
|
||||
"collapse_all": "",
|
||||
"expand_all": "",
|
||||
"log_level": "",
|
||||
"clear_logs": "",
|
||||
"reconnect": "",
|
||||
"details": "",
|
||||
"hide_details": "",
|
||||
"copy": "",
|
||||
"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": ""
|
||||
"response_xml": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"transport_ws": "",
|
||||
"transport_wss": ""
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -1174,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": {
|
||||
@@ -3374,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": {
|
||||
@@ -3591,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "",
|
||||
"amount": "",
|
||||
"ciecacode": "",
|
||||
"clockhours": "",
|
||||
"clockoff": "",
|
||||
@@ -3605,7 +3629,10 @@
|
||||
"employee_team": "",
|
||||
"flat_rate": "",
|
||||
"memo": "",
|
||||
"pay": "",
|
||||
"payout_method": "",
|
||||
"productivehrs": "",
|
||||
"rate": "",
|
||||
"ro_number": "",
|
||||
"task_name": ""
|
||||
},
|
||||
@@ -3624,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,36 +1079,36 @@
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": "",
|
||||
"provider_reynolds": "",
|
||||
"provider_fortellis": "",
|
||||
"provider_cdk": "",
|
||||
"provider_pbs": "",
|
||||
"provider_dms": "",
|
||||
"transport_wss": "",
|
||||
"transport_ws": "",
|
||||
"banner_message": "",
|
||||
"banner_status_connected": "",
|
||||
"banner_status_disconnected": "",
|
||||
"banner_message": "",
|
||||
"reconnected_export_service": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"color_json": "",
|
||||
"plain_json": "",
|
||||
"collapse_all": "",
|
||||
"expand_all": "",
|
||||
"log_level": "",
|
||||
"clear_logs": "",
|
||||
"reconnect": "",
|
||||
"details": "",
|
||||
"hide_details": "",
|
||||
"copy": "",
|
||||
"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": ""
|
||||
"response_xml": "",
|
||||
"rr_validation_message": "",
|
||||
"rr_validation_notice_description": "",
|
||||
"rr_validation_notice_title": "",
|
||||
"transport_ws": "",
|
||||
"transport_wss": ""
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -1174,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": {
|
||||
@@ -3374,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": {
|
||||
@@ -3591,6 +3614,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"actualhrs": "",
|
||||
"amount": "",
|
||||
"ciecacode": "",
|
||||
"clockhours": "",
|
||||
"clockoff": "",
|
||||
@@ -3605,7 +3629,10 @@
|
||||
"employee_team": "",
|
||||
"flat_rate": "",
|
||||
"memo": "",
|
||||
"pay": "",
|
||||
"payout_method": "",
|
||||
"productivehrs": "",
|
||||
"rate": "",
|
||||
"ro_number": "",
|
||||
"task_name": ""
|
||||
},
|
||||
@@ -3624,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
|
||||
};
|
||||
@@ -212,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -2463,6 +2463,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
}
|
||||
percentage
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2473,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
productivehrs
|
||||
actualhrs
|
||||
ciecacode
|
||||
payout_context
|
||||
}
|
||||
lbr_adjustments
|
||||
ro_number
|
||||
@@ -2564,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
}
|
||||
percentage
|
||||
labor_rates
|
||||
payout_method
|
||||
commission_rates
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2574,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
||||
productivehrs
|
||||
actualhrs
|
||||
ciecacode
|
||||
payout_context
|
||||
}
|
||||
lbr_adjustments
|
||||
ro_number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
|
||||
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
|
||||
const getPaymentType = require("./getPaymentType");
|
||||
const moment = require("moment");
|
||||
const moment = require("moment-timezone");
|
||||
|
||||
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
|
||||
const { sendTaskEmail } = require("../../email/sendemail");
|
||||
const getPaymentType = require("./getPaymentType");
|
||||
const moment = require("moment");
|
||||
const moment = require("moment-timezone");
|
||||
|
||||
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { mockSend } = vi.hoisted(() => ({
|
||||
mockSend: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-secrets-manager", () => {
|
||||
return {
|
||||
SecretsManagerClient: vi.fn(() => ({
|
||||
send: mockSend
|
||||
})),
|
||||
GetSecretValueCommand: vi.fn((input) => input)
|
||||
};
|
||||
});
|
||||
|
||||
const getPaymentType = require("../getPaymentType");
|
||||
const decodeComment = require("../decodeComment");
|
||||
const getCptellerUrl = require("../getCptellerUrl");
|
||||
@@ -145,28 +158,15 @@ describe("Payment Processing Functions", () => {
|
||||
// GetShopCredentials Tests
|
||||
describe("getShopCredentials", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let mockSend;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend = vi.fn();
|
||||
vi.mock("@aws-sdk/client-secrets-manager", () => {
|
||||
return {
|
||||
SecretsManagerClient: vi.fn(() => ({
|
||||
send: mockSend
|
||||
})),
|
||||
GetSecretValueCommand: vi.fn((input) => input)
|
||||
};
|
||||
});
|
||||
|
||||
mockSend.mockReset();
|
||||
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
|
||||
process.env.INTELLIPAY_APIKEY = "test-api-key";
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
vi.unmock("@aws-sdk/client-secrets-manager");
|
||||
});
|
||||
|
||||
it("returns environment variables in non-production environment", async () => {
|
||||
|
||||
@@ -315,7 +315,12 @@ function CalculateRatesTotals(ratesList) {
|
||||
if (item.mod_lbr_ty) {
|
||||
//Check to see if it has 0 hours and a price instead.
|
||||
//Extend for when there are hours and a price.
|
||||
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0) && !IsAdditionalCost(item)) {
|
||||
if (
|
||||
item.lbr_op === "OP14" &&
|
||||
item.act_price > 0 &&
|
||||
(!item.part_type || item.mod_lb_hrs === 0) &&
|
||||
!IsAdditionalCost(item)
|
||||
) {
|
||||
//Scenario where SGI may pay out hours using a part price.
|
||||
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
|
||||
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();
|
||||
@@ -339,38 +344,30 @@ function CalculateRatesTotals(ratesList) {
|
||||
let subtotal = Dinero({ amount: 0 });
|
||||
let rates_subtotal = Dinero({ amount: 0 });
|
||||
|
||||
for (const property in ret) {
|
||||
for (const [property, values] of Object.entries(ret)) {
|
||||
//Skip calculating mapa and mash if we got the amounts.
|
||||
if (!((property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine))) {
|
||||
if (!ret[property].total) {
|
||||
ret[property].total = Dinero();
|
||||
}
|
||||
let threshold;
|
||||
//Check if there is a max for this type.
|
||||
if (ratesList.materials && ratesList.materials[property]) {
|
||||
//
|
||||
if (ratesList.materials[property].cal_maxdlr && ratesList.materials[property].cal_maxdlr > 0) {
|
||||
//It has an upper threshhold.
|
||||
threshold = Dinero({
|
||||
amount: Math.round(ratesList.materials[property].cal_maxdlr * 100)
|
||||
});
|
||||
}
|
||||
}
|
||||
const shouldSkipCalculation = (property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine);
|
||||
|
||||
if (!shouldSkipCalculation) {
|
||||
values.total ??= Dinero();
|
||||
|
||||
//Check if there is a max for this type and apply it.
|
||||
const maxDollar =
|
||||
ratesList.materials?.[property]?.cal_maxdlr || ratesList.materials?.[property.toUpperCase()]?.cal_maxdlr;
|
||||
const threshold = maxDollar > 0 ? Dinero({ amount: Math.round(maxDollar * 100) }) : null;
|
||||
|
||||
const total = Dinero({
|
||||
amount: Math.round((ret[property].rate || 0) * 100)
|
||||
}).multiply(ret[property].hours);
|
||||
amount: Math.round((values.rate || 0) * 100)
|
||||
}).multiply(values.hours);
|
||||
|
||||
if (threshold && total.greaterThanOrEqual(threshold)) {
|
||||
ret[property].total = ret[property].total.add(threshold);
|
||||
} else {
|
||||
ret[property].total = ret[property].total.add(total);
|
||||
}
|
||||
values.total = values.total.add(threshold && total.greaterThanOrEqual(threshold) ? threshold : total);
|
||||
}
|
||||
|
||||
subtotal = subtotal.add(ret[property].total);
|
||||
subtotal = subtotal.add(values.total);
|
||||
|
||||
if (property !== "mapa" && property !== "mash") rates_subtotal = rates_subtotal.add(ret[property].total);
|
||||
if (property !== "mapa" && property !== "mash") {
|
||||
rates_subtotal = rates_subtotal.add(values.total);
|
||||
}
|
||||
}
|
||||
|
||||
ret.subtotal = subtotal;
|
||||
|
||||
@@ -35,6 +35,11 @@ describe("TotalsServerSide fixture tests", () => {
|
||||
|
||||
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
if (fixtureFiles.length === 0) {
|
||||
it.skip("skips when no job total fixtures are present", () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const dummyClient = {
|
||||
request: async () => {
|
||||
return {};
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
const Dinero = require("dinero.js");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const logger = require("../utils/logger");
|
||||
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all");
|
||||
|
||||
// Dinero.defaultCurrency = "USD";
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
const get = (obj, key) => {
|
||||
return key.split(".").reduce((o, x) => {
|
||||
return typeof o == "undefined" || o === null ? o : o[x];
|
||||
}, obj);
|
||||
};
|
||||
|
||||
exports.calculatelabor = async function (req, res) {
|
||||
const { jobid, calculateOnly } = req.body;
|
||||
const { jobid } = req.body;
|
||||
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
|
||||
|
||||
const BearerToken = req.BearerToken;
|
||||
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
|
||||
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
||||
//At the employee level.
|
||||
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||
//At the labor level
|
||||
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
|
||||
//At the rate level.
|
||||
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey];
|
||||
//Will the following line fail? Probably if it doesn't exist.
|
||||
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
|
||||
if (claimedHours) {
|
||||
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
|
||||
}
|
||||
const expected = employeeHash[employeeIdKey][laborTypeKey];
|
||||
const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
|
||||
|
||||
totals.push({
|
||||
employeeid: employeeIdKey,
|
||||
rate: rateKey,
|
||||
mod_lbr_ty: laborTypeKey,
|
||||
expectedHours,
|
||||
claimedHours: claimedHours || 0
|
||||
});
|
||||
if (claimed) {
|
||||
delete ticketHash[employeeIdKey][laborTypeKey];
|
||||
}
|
||||
|
||||
totals.push({
|
||||
employeeid: employeeIdKey,
|
||||
rate: expected.rate,
|
||||
mod_lbr_ty: laborTypeKey,
|
||||
expectedHours: expected.hours,
|
||||
claimedHours: claimed?.hours || 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) {
|
||||
Object.keys(ticketHash).forEach((employeeIdKey) => {
|
||||
//At the employee level.
|
||||
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||
//At the labor level
|
||||
Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
|
||||
//At the rate level.
|
||||
const expectedHours = 0;
|
||||
//Will the following line fail? Probably if it doesn't exist.
|
||||
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
|
||||
if (claimedHours) {
|
||||
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
|
||||
}
|
||||
const claimed = ticketHash[employeeIdKey][laborTypeKey];
|
||||
|
||||
totals.push({
|
||||
employeeid: employeeIdKey,
|
||||
rate: rateKey,
|
||||
mod_lbr_ty: laborTypeKey,
|
||||
expectedHours,
|
||||
claimedHours: claimedHours || 0
|
||||
});
|
||||
totals.push({
|
||||
employeeid: employeeIdKey,
|
||||
rate: claimed.rate,
|
||||
mod_lbr_ty: laborTypeKey,
|
||||
expectedHours: 0,
|
||||
claimedHours: claimed.hours || 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
|
||||
jobid: jobid,
|
||||
error
|
||||
});
|
||||
res.status(503).send();
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
const Dinero = require("dinero.js");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const logger = require("../utils/logger");
|
||||
const { CalculateExpectedHoursForJob } = require("./pay-all");
|
||||
const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
|
||||
const moment = require("moment");
|
||||
// Dinero.defaultCurrency = "USD";
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
|
||||
|
||||
const getTaskPresetAllocationError = (taskPresets = []) => {
|
||||
const totalsByLaborType = {};
|
||||
|
||||
taskPresets.forEach((taskPreset) => {
|
||||
const percent = normalizePercent(taskPreset?.percent);
|
||||
|
||||
if (!percent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : [];
|
||||
|
||||
laborTypes.forEach((laborType) => {
|
||||
if (!laborType) {
|
||||
return;
|
||||
}
|
||||
|
||||
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
|
||||
});
|
||||
});
|
||||
|
||||
const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100);
|
||||
|
||||
if (!overAllocatedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [laborType, total] = overAllocatedType;
|
||||
return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`;
|
||||
};
|
||||
|
||||
exports.GetTaskPresetAllocationError = getTaskPresetAllocationError;
|
||||
|
||||
exports.claimtask = async function (req, res) {
|
||||
const { jobid, task, calculateOnly, employee } = req.body;
|
||||
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
|
||||
id: jobid
|
||||
});
|
||||
|
||||
const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task);
|
||||
const taskPresets = job.bodyshop?.md_tasks_presets?.presets || [];
|
||||
const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets);
|
||||
if (taskPresetAllocationError) {
|
||||
res.status(400).json({ success: false, error: taskPresetAllocationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const theTaskPreset = taskPresets.find((tp) => tp.name === task);
|
||||
if (!theTaskPreset) {
|
||||
res.status(400).json({ success: false, error: "Provided task preset not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task);
|
||||
if (taskAlreadyCompleted) {
|
||||
res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." });
|
||||
return;
|
||||
}
|
||||
|
||||
//Get all of the assignments that are filtered.
|
||||
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
|
||||
const ticketsToInsert = [];
|
||||
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
|
||||
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
||||
//At the employee level.
|
||||
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||
//At the labor level
|
||||
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
|
||||
//At the rate level.
|
||||
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
|
||||
const expected = employeeHash[employeeIdKey][laborTypeKey];
|
||||
const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
|
||||
|
||||
ticketsToInsert.push({
|
||||
task_name: task,
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: employeeIdKey,
|
||||
productivehrs: expectedHours,
|
||||
rate: rateKey,
|
||||
ciecacode: laborTypeKey,
|
||||
flat_rate: true,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
|
||||
memo: `*Flagged Task* ${theTaskPreset.memo}`
|
||||
});
|
||||
ticketsToInsert.push({
|
||||
task_name: task,
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: employeeIdKey,
|
||||
productivehrs: expectedHours,
|
||||
rate: expected.rate,
|
||||
ciecacode: laborTypeKey,
|
||||
flat_rate: true,
|
||||
created_by: employee?.name || req.user.email,
|
||||
payout_context: {
|
||||
...(expected.payoutContext || {}),
|
||||
generated_by: req.user.email,
|
||||
generated_at: new Date().toISOString(),
|
||||
generated_from: "claimtask",
|
||||
task_name: task
|
||||
},
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
|
||||
memo: `*Flagged Task* ${theTaskPreset.memo}`
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!calculateOnly) {
|
||||
//Insert the time ticekts if we're not just calculating them.
|
||||
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
|
||||
await client.request(queries.INSERT_TIME_TICKETS, {
|
||||
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
|
||||
});
|
||||
const updateResult = await client.request(queries.UPDATE_JOB, {
|
||||
await client.request(queries.UPDATE_JOB, {
|
||||
jobId: job.id,
|
||||
job: {
|
||||
status: theTaskPreset.nextstatus,
|
||||
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
|
||||
jobid: jobid,
|
||||
error
|
||||
});
|
||||
res.status(503).send();
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,196 @@
|
||||
const Dinero = require("dinero.js");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const rdiff = require("recursive-diff");
|
||||
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
// Dinero.defaultCurrency = "USD";
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
Dinero.globalFormatRoundingMode = "HALF_EVEN";
|
||||
|
||||
const PAYOUT_METHODS = {
|
||||
hourly: "hourly",
|
||||
commission: "commission"
|
||||
};
|
||||
|
||||
const CURRENCY_PRECISION = 2;
|
||||
const HOURS_PRECISION = 5;
|
||||
|
||||
const toNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const normalizeNumericString = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const asString = value.toString();
|
||||
|
||||
if (!asString.toLowerCase().includes("e")) {
|
||||
return asString;
|
||||
}
|
||||
|
||||
return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
return `${value ?? ""}`.trim();
|
||||
};
|
||||
|
||||
const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => {
|
||||
const normalizedValue = normalizeNumericString(value);
|
||||
const parsedValue = Number(normalizedValue);
|
||||
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const isNegative = normalizedValue.startsWith("-");
|
||||
const unsignedValue = normalizedValue.replace(/^[+-]/, "");
|
||||
const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split(".");
|
||||
const wholeDigits = wholePart.replace(/\D/g, "") || "0";
|
||||
const fractionDigits = fractionPartRaw.replace(/\D/g, "");
|
||||
const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1);
|
||||
|
||||
return Dinero({
|
||||
amount,
|
||||
precision: fractionDigits.length
|
||||
});
|
||||
};
|
||||
|
||||
const roundValueWithDinero = (value, precision, errorMessage) =>
|
||||
decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit();
|
||||
|
||||
const roundCurrency = (value, errorMessage = "Invalid currency value.") =>
|
||||
roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage);
|
||||
|
||||
const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage);
|
||||
|
||||
const normalizePayoutMethod = (value) =>
|
||||
value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly;
|
||||
|
||||
const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
|
||||
|
||||
const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`;
|
||||
|
||||
const getTeamMemberLabel = (teamMember) => {
|
||||
const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim();
|
||||
return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee";
|
||||
};
|
||||
|
||||
const parseRequiredNumber = (value, errorMessage) => {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const buildFallbackPayoutContext = ({ laborType, rate }) => ({
|
||||
payout_type: "legacy",
|
||||
payout_method: "legacy",
|
||||
cut_percent_applied: null,
|
||||
source_labor_rate: null,
|
||||
source_labor_type: laborType,
|
||||
effective_rate: roundCurrency(rate)
|
||||
});
|
||||
|
||||
function BuildPayoutDetails(job, teamMember, laborType) {
|
||||
const payoutMethod = normalizePayoutMethod(teamMember?.payout_method);
|
||||
const teamMemberLabel = getTeamMemberLabel(teamMember);
|
||||
const sourceLaborRateField = getJobSaleRateField(laborType);
|
||||
|
||||
if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) {
|
||||
throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`);
|
||||
}
|
||||
|
||||
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) {
|
||||
throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`);
|
||||
}
|
||||
|
||||
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) {
|
||||
throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`);
|
||||
}
|
||||
|
||||
const hourlyRate =
|
||||
payoutMethod === PAYOUT_METHODS.hourly
|
||||
? roundCurrency(
|
||||
parseRequiredNumber(
|
||||
teamMember?.labor_rates?.[laborType],
|
||||
`Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
const commissionPercent =
|
||||
payoutMethod === PAYOUT_METHODS.commission
|
||||
? roundCurrency(
|
||||
parseRequiredNumber(
|
||||
teamMember?.commission_rates?.[laborType],
|
||||
`Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.`
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) {
|
||||
throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`);
|
||||
}
|
||||
|
||||
const sourceLaborRate =
|
||||
payoutMethod === PAYOUT_METHODS.commission
|
||||
? roundCurrency(
|
||||
parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`)
|
||||
)
|
||||
: null;
|
||||
|
||||
const effectiveRate =
|
||||
payoutMethod === PAYOUT_METHODS.commission
|
||||
? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100)
|
||||
: hourlyRate;
|
||||
|
||||
return {
|
||||
effectiveRate,
|
||||
payoutContext: {
|
||||
payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly",
|
||||
payout_method: payoutMethod,
|
||||
cut_percent_applied: commissionPercent,
|
||||
source_labor_rate: sourceLaborRate,
|
||||
source_labor_type: laborType,
|
||||
effective_rate: effectiveRate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) {
|
||||
return {
|
||||
...(baseContext || {}),
|
||||
generated_by: generatedBy,
|
||||
generated_at: new Date().toISOString(),
|
||||
generated_from: generatedFrom,
|
||||
task_name: taskName,
|
||||
used_ticket_fallback: Boolean(usedTicketFallback)
|
||||
};
|
||||
}
|
||||
|
||||
function getAllKeys(...objects) {
|
||||
return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))];
|
||||
}
|
||||
|
||||
function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) {
|
||||
if (!hasClaimed && deltaHours > 0) {
|
||||
return `Add unflagged hours. (${userEmail})`;
|
||||
}
|
||||
|
||||
if (!hasExpected && deltaHours < 0) {
|
||||
return `Remove flagged hours per assignment. (${userEmail})`;
|
||||
}
|
||||
|
||||
return `Adjust flagged hours per assignment. (${userEmail})`;
|
||||
}
|
||||
|
||||
exports.payall = async function (req, res) {
|
||||
const { jobid, calculateOnly } = req.body;
|
||||
const { jobid } = req.body;
|
||||
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
|
||||
|
||||
const BearerToken = req.BearerToken;
|
||||
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
|
||||
id: jobid
|
||||
});
|
||||
|
||||
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
|
||||
|
||||
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
|
||||
const ticketHash = CalculateTicketsHoursForJob(job);
|
||||
|
||||
if (assignmentHash.unassigned > 0) {
|
||||
res.json({ success: false, error: "Not all hours have been assigned." });
|
||||
return;
|
||||
}
|
||||
|
||||
//Calculate how much time each tech should have by labor type.
|
||||
//Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
|
||||
const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
|
||||
|
||||
const ticketsToInsert = [];
|
||||
const employeeIds = getAllKeys(employeeHash, ticketHash);
|
||||
|
||||
recursiveDiff.forEach((diff) => {
|
||||
//Every iteration is what we would need to insert into the time ticket hash
|
||||
//so that it would match the employee hash exactly.
|
||||
const path = diffParser(diff);
|
||||
employeeIds.forEach((employeeId) => {
|
||||
const expectedByLabor = employeeHash[employeeId] || {};
|
||||
const claimedByLabor = ticketHash[employeeId] || {};
|
||||
|
||||
if (diff.op === "add") {
|
||||
// console.log(Object.keys(diff.val));
|
||||
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
|
||||
//Multiple values to add.
|
||||
Object.keys(diff.val).forEach((key) => {
|
||||
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
|
||||
// console.log("Rate", Object.keys(diff.val[key])[0]);
|
||||
ticketsToInsert.push({
|
||||
task_name: "Pay All",
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: path.employeeid,
|
||||
productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
|
||||
rate: Object.keys(diff.val[key])[0],
|
||||
ciecacode: key,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
|
||||
flat_rate: true,
|
||||
memo: `Add unflagged hours. (${req.user.email})`
|
||||
});
|
||||
});
|
||||
} else {
|
||||
//Only the 1 value to add.
|
||||
ticketsToInsert.push({
|
||||
task_name: "Pay All",
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: path.employeeid,
|
||||
productivehrs: path.hours,
|
||||
rate: path.rate,
|
||||
ciecacode: path.mod_lbr_ty,
|
||||
flat_rate: true,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
|
||||
memo: `Add unflagged hours. (${req.user.email})`
|
||||
});
|
||||
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
|
||||
const expected = expectedByLabor[laborType];
|
||||
const claimed = claimedByLabor[laborType];
|
||||
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
|
||||
|
||||
if (deltaHours === 0) {
|
||||
return;
|
||||
}
|
||||
} else if (diff.op === "update") {
|
||||
//An old ticket amount isn't sufficient
|
||||
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
|
||||
|
||||
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
|
||||
const payoutContext = BuildGeneratedPayoutContext({
|
||||
baseContext:
|
||||
expected?.payoutContext ||
|
||||
claimed?.payoutContext ||
|
||||
buildFallbackPayoutContext({ laborType, rate: effectiveRate }),
|
||||
generatedBy: req.user.email,
|
||||
generatedFrom: "payall",
|
||||
taskName: "Pay All",
|
||||
usedTicketFallback: !expected && Boolean(claimed)
|
||||
});
|
||||
|
||||
ticketsToInsert.push({
|
||||
task_name: "Pay All",
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: path.employeeid,
|
||||
productivehrs: diff.val - diff.oldVal,
|
||||
rate: path.rate,
|
||||
ciecacode: path.mod_lbr_ty,
|
||||
employeeid: employeeId,
|
||||
productivehrs: deltaHours,
|
||||
rate: effectiveRate,
|
||||
ciecacode: laborType,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
|
||||
flat_rate: true,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
|
||||
memo: `Adjust flagged hours per assignment. (${req.user.email})`
|
||||
created_by: req.user.email,
|
||||
payout_context: payoutContext,
|
||||
memo: buildPayAllMemo({
|
||||
deltaHours,
|
||||
hasExpected: Boolean(expected),
|
||||
hasClaimed: Boolean(claimed),
|
||||
userEmail: req.user.email
|
||||
})
|
||||
});
|
||||
} else {
|
||||
//Has to be a delete
|
||||
if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) {
|
||||
//Multiple oldValues to add.
|
||||
Object.keys(diff.oldVal).forEach((key) => {
|
||||
ticketsToInsert.push({
|
||||
task_name: "Pay All",
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: path.employeeid,
|
||||
productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
|
||||
rate: Object.keys(diff.oldVal[key])[0],
|
||||
ciecacode: key,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
|
||||
flat_rate: true,
|
||||
memo: `Remove flagged hours per assignment. (${req.user.email})`
|
||||
});
|
||||
});
|
||||
} else {
|
||||
//Only the 1 value to add.
|
||||
ticketsToInsert.push({
|
||||
task_name: "Pay All",
|
||||
jobid: job.id,
|
||||
bodyshopid: job.bodyshop.id,
|
||||
employeeid: path.employeeid,
|
||||
productivehrs: path.hours * -1,
|
||||
rate: path.rate,
|
||||
ciecacode: path.mod_lbr_ty,
|
||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
|
||||
flat_rate: true,
|
||||
memo: `Remove flagged hours per assignment. (${req.user.email})`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
|
||||
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
|
||||
});
|
||||
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
|
||||
|
||||
res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0));
|
||||
if (filteredTickets.length > 0) {
|
||||
await client.request(queries.INSERT_TIME_TICKETS, {
|
||||
timetickets: filteredTickets
|
||||
});
|
||||
}
|
||||
|
||||
res.json(filteredTickets);
|
||||
} catch (error) {
|
||||
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
|
||||
jobid: jobid,
|
||||
jobid,
|
||||
error: JSON.stringify(error)
|
||||
});
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
function diffParser(diff) {
|
||||
const type = typeof diff.oldVal;
|
||||
let mod_lbr_ty, rate, hours;
|
||||
|
||||
if (diff.path.length === 1) {
|
||||
if (diff.op === "add") {
|
||||
mod_lbr_ty = Object.keys(diff.val)[0];
|
||||
rate = Object.keys(diff.val[mod_lbr_ty])[0];
|
||||
// hours = diff.oldVal[mod_lbr_ty][rate];
|
||||
} else {
|
||||
mod_lbr_ty = Object.keys(diff.oldVal)[0];
|
||||
rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
|
||||
// hours = diff.oldVal[mod_lbr_ty][rate];
|
||||
}
|
||||
} else if (diff.path.length === 2) {
|
||||
mod_lbr_ty = diff.path[1];
|
||||
if (diff.op === "add") {
|
||||
rate = Object.keys(diff.val)[0];
|
||||
} else {
|
||||
rate = Object.keys(diff.oldVal)[0];
|
||||
}
|
||||
} else if (diff.path.length === 3) {
|
||||
mod_lbr_ty = diff.path[1];
|
||||
rate = diff.path[2];
|
||||
//hours = 0;
|
||||
}
|
||||
|
||||
//Set the hours
|
||||
if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) {
|
||||
hours = diff.val;
|
||||
} else if (diff.val !== null && diff.val !== undefined) {
|
||||
if (diff.path.length === 1) {
|
||||
hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])];
|
||||
} else {
|
||||
hours = diff.val[Object.keys(diff.val)[0]];
|
||||
}
|
||||
} else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) {
|
||||
hours = diff.oldVal;
|
||||
} else {
|
||||
hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
|
||||
}
|
||||
|
||||
const ret = {
|
||||
multiVal: false,
|
||||
employeeid: diff.path[0], // Always True
|
||||
mod_lbr_ty,
|
||||
rate,
|
||||
hours
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
|
||||
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
|
||||
const assignmentHash = { unassigned: 0 };
|
||||
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
|
||||
const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
|
||||
const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
|
||||
|
||||
job.joblines
|
||||
.filter((jobline) => {
|
||||
if (!filterToLbrTypes) return true;
|
||||
else {
|
||||
return (
|
||||
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
|
||||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
|
||||
);
|
||||
if (!laborTypeFilter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
|
||||
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
|
||||
})
|
||||
.forEach((jobline) => {
|
||||
if (jobline.convertedtolbr) {
|
||||
// Line has been converte to labor. Temporarily re-assign the hours.
|
||||
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
|
||||
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
|
||||
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
|
||||
const laborHours = roundHours(
|
||||
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
|
||||
);
|
||||
|
||||
if (laborHours === 0) {
|
||||
return;
|
||||
}
|
||||
if (jobline.mod_lb_hrs != 0) {
|
||||
//Check if the line is assigned. If not, keep track of it as an unassigned line by type.
|
||||
if (jobline.assigned_team === null) {
|
||||
assignmentHash.unassigned = assignmentHash.unassigned + jobline.mod_lb_hrs;
|
||||
} else {
|
||||
//Line is assigned.
|
||||
if (!assignmentHash[jobline.assigned_team]) {
|
||||
assignmentHash[jobline.assigned_team] = 0;
|
||||
}
|
||||
assignmentHash[jobline.assigned_team] = assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs;
|
||||
|
||||
//Create the assignment breakdown.
|
||||
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
|
||||
if (jobline.assigned_team === null) {
|
||||
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
|
||||
return;
|
||||
}
|
||||
|
||||
theTeam.employee_team_members.forEach((tm) => {
|
||||
//Figure out how many hours they are owed at this line, and at what rate.
|
||||
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
|
||||
|
||||
if (!employeeHash[tm.employee.id]) {
|
||||
employeeHash[tm.employee.id] = {};
|
||||
}
|
||||
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) {
|
||||
employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {};
|
||||
}
|
||||
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]]) {
|
||||
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = 0;
|
||||
}
|
||||
if (!theTeam) {
|
||||
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
|
||||
return;
|
||||
}
|
||||
|
||||
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
|
||||
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] =
|
||||
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] + hoursOwed;
|
||||
});
|
||||
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
|
||||
|
||||
theTeam.employee_team_members.forEach((teamMember) => {
|
||||
const employeeId = teamMember.employee.id;
|
||||
const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType);
|
||||
|
||||
if (!employeeHash[employeeId]) {
|
||||
employeeHash[employeeId] = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!employeeHash[employeeId][laborType]) {
|
||||
employeeHash[employeeId][laborType] = {
|
||||
hours: 0,
|
||||
rate: effectiveRate,
|
||||
payoutContext
|
||||
};
|
||||
}
|
||||
|
||||
const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100);
|
||||
employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed);
|
||||
employeeHash[employeeId][laborType].rate = effectiveRate;
|
||||
employeeHash[employeeId][laborType].payoutContext = payoutContext;
|
||||
});
|
||||
});
|
||||
|
||||
return { assignmentHash, employeeHash };
|
||||
}
|
||||
|
||||
function CalculateTicketsHoursForJob(job) {
|
||||
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
|
||||
//Calculate how much each employee has been paid so far.
|
||||
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
|
||||
|
||||
job.timetickets.forEach((ticket) => {
|
||||
if (!ticket?.employeeid || !ticket?.ciecacode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ticketHash[ticket.employeeid]) {
|
||||
ticketHash[ticket.employeeid] = {};
|
||||
}
|
||||
|
||||
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode] = {};
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode] = {
|
||||
hours: 0,
|
||||
rate: roundCurrency(ticket.rate),
|
||||
payoutContext: ticket.payout_context || null
|
||||
};
|
||||
}
|
||||
if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
|
||||
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours(
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs)
|
||||
);
|
||||
|
||||
if (ticket.rate !== null && ticket.rate !== undefined) {
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate);
|
||||
}
|
||||
|
||||
if (ticket.payout_context) {
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context;
|
||||
}
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
|
||||
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs;
|
||||
});
|
||||
|
||||
return ticketHash;
|
||||
}
|
||||
|
||||
exports.BuildPayoutDetails = BuildPayoutDetails;
|
||||
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
|
||||
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
|
||||
exports.RoundPayrollHours = roundHours;
|
||||
|
||||
1103
server/payroll/payroll.test.js
Normal file
1103
server/payroll/payroll.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ const multer = require("multer");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
const { handleBillOcr, handleBillOcrStatus } = require("../ai/bill-ocr/bill-ocr");
|
||||
const { handleBillAiFeedback } = require("../ai/bill-ai-feedback");
|
||||
|
||||
// Configure multer for form data parsing
|
||||
const upload = multer();
|
||||
// Configure multer for form data parsing (memory storage)
|
||||
const upload = multer({
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB
|
||||
}
|
||||
});
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(withUserGraphQLClientMiddleware);
|
||||
@@ -14,4 +19,6 @@ router.use(withUserGraphQLClientMiddleware);
|
||||
router.post("/bill-ocr", upload.single('billScan'), handleBillOcr);
|
||||
router.get("/bill-ocr/status/:textractJobId", handleBillOcrStatus);
|
||||
|
||||
router.post("/bill-feedback", upload.single("billPdf"), handleBillAiFeedback);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user