Compare commits
147 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f849ea9d0a | ||
|
|
de6038038a | ||
|
|
1f8836d9d8 | ||
|
|
a267d65425 | ||
|
|
cacda3805a | ||
|
|
af757ee71e | ||
|
|
eb666f2ca1 | ||
|
|
2b8990950b | ||
|
|
3f2e05befc | ||
|
|
06bfdeb449 | ||
|
|
66df286ddb | ||
|
|
1b2f9fc027 | ||
|
|
1287c7ec36 | ||
|
|
fb29fa2caa | ||
|
|
6bda497d8c | ||
|
|
a018b6dc5a | ||
|
|
8a4679f86c | ||
|
|
4d558da46a | ||
|
|
90789e743f | ||
|
|
a4dbc5250e | ||
|
|
704543d823 | ||
|
|
d8924d6cf3 | ||
|
|
a1d0e2df93 | ||
|
|
9a86a337bb | ||
|
|
fe848b5de4 | ||
|
|
a287601f27 | ||
|
|
7688f22161 | ||
|
|
2cc6774334 | ||
|
|
efdcd06921 | ||
|
|
d2dd276ce7 | ||
|
|
c0a37d7c1a | ||
|
|
6947ad54a7 | ||
|
|
6759bc5865 | ||
|
|
db52bf0e94 | ||
|
|
04732fc6cd | ||
|
|
5d95275c0b | ||
|
|
a65a34ef1f | ||
|
|
1ea7798eeb | ||
|
|
7739d48741 | ||
|
|
ed0693fc5b | ||
|
|
074be66b8c | ||
|
|
8db8744782 | ||
|
|
c2d8d78e0a | ||
|
|
e9e189d032 | ||
|
|
71aec6d0c5 | ||
|
|
f89d7865fa | ||
|
|
8fd368ebb4 | ||
|
|
132fc0a20f | ||
|
|
0b470e3c31 | ||
|
|
ab8b44bee4 | ||
|
|
9ea2d83043 | ||
|
|
abad7d5f00 | ||
|
|
d497ec9f7d | ||
|
|
e49500887d | ||
|
|
b8246e03c1 | ||
|
|
cc623b7cbb | ||
|
|
3aa19ec09f | ||
|
|
c97213bc96 | ||
|
|
866e9581c2 | ||
|
|
1102670e66 | ||
|
|
591439b79c | ||
|
|
2de605e520 | ||
|
|
2690e09626 | ||
|
|
dd306e1a7b | ||
|
|
9b1488ac3b | ||
|
|
7bab9bf4cb | ||
|
|
8278242e6f | ||
|
|
fd712da4a3 | ||
|
|
bcb693f03c | ||
|
|
c33a3118bc | ||
|
|
d23a182650 | ||
|
|
aa81cddcf1 | ||
|
|
85e60dcd6b | ||
|
|
a005f1bb45 | ||
|
|
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
|
/CLAUDE.md
|
||||||
/COPILOT.md
|
/COPILOT.md
|
||||||
/GEMINI.md
|
/GEMINI.md
|
||||||
/_reference/select-component-test-plan.md
|
|
||||||
|
|
||||||
/.cursorrules
|
/.cursorrules
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
/AI_CONTEXT.md
|
/AI_CONTEXT.md
|
||||||
@@ -151,4 +149,3 @@ docker_data
|
|||||||
/COPILOT.md
|
/COPILOT.md
|
||||||
/.github/copilot-instructions.md
|
/.github/copilot-instructions.md
|
||||||
/GEMINI.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
|
```shell
|
||||||
node index.js
|
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 express from "express";
|
||||||
import fetch from "node-fetch";
|
import { readFileSync } from "node:fs";
|
||||||
import { simpleParser } from "mailparser";
|
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 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 {
|
try {
|
||||||
const response = await fetch("http://localhost:4566/_aws/ses");
|
res.json(await loadServiceHealthSummary());
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching messages:", error);
|
console.error("Error fetching service health:", error);
|
||||||
res.status(500).send("Error fetching messages");
|
res.status(502).json({
|
||||||
|
error: "Unable to fetch LocalStack service health",
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function parseMessages(messages) {
|
app.get("/api/messages", async (req, res) => {
|
||||||
const parsedMessages = await Promise.all(
|
try {
|
||||||
messages.map(async (message, index) => {
|
res.json(await loadMessages());
|
||||||
try {
|
} catch (error) {
|
||||||
const parsed = await simpleParser(message.RawData);
|
console.error("Error fetching messages:", error);
|
||||||
return `
|
res.status(502).json({
|
||||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
error: "Unable to fetch messages from LocalStack SES",
|
||||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
details: error.message,
|
||||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
endpoint: SES_ENDPOINT
|
||||||
<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("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHtml(messagesHtml) {
|
app.get("/api/messages/:id/raw", async (req, res) => {
|
||||||
return `
|
try {
|
||||||
<!DOCTYPE html>
|
const message = await findSesMessageById(req.params.id);
|
||||||
<html lang="en">
|
|
||||||
<head>
|
if (!message) {
|
||||||
<meta charset="UTF-8">
|
res.status(404).type("text/plain").send("Message not found");
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
return;
|
||||||
<title>Email Messages Viewer</title>
|
}
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
res.type("text/plain").send(message.RawData || "");
|
||||||
body {
|
} catch (error) {
|
||||||
background-color: #f3f4f6;
|
console.error("Error fetching raw message:", error);
|
||||||
font-family: Arial, sans-serif;
|
res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`);
|
||||||
}
|
}
|
||||||
.container {
|
});
|
||||||
max-width: 800px;
|
|
||||||
margin: 50px auto;
|
app.get("/api/messages/:id/attachments/:index", async (req, res) => {
|
||||||
padding: 20px;
|
try {
|
||||||
}
|
const attachmentIndex = Number.parseInt(req.params.index, 10);
|
||||||
.prose {
|
|
||||||
line-height: 1.6;
|
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) {
|
||||||
}
|
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer");
|
||||||
</style>
|
return;
|
||||||
</head>
|
}
|
||||||
<body>
|
|
||||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
const attachment = await loadMessageAttachment(req.params.id, attachmentIndex);
|
||||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
|
||||||
<div id="messages-container">${messagesHtml}</div>
|
if (!attachment) {
|
||||||
</div>
|
res.status(404).type("text/plain").send("Attachment not found");
|
||||||
</body>
|
return;
|
||||||
</html>
|
}
|
||||||
`;
|
|
||||||
}
|
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, () => {
|
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",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3",
|
||||||
"dependencies": {
|
"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",
|
"express": "^5.1.0",
|
||||||
"mailparser": "^3.7.4",
|
"mailparser": "^3.7.4",
|
||||||
"node-fetch": "^3.3.2"
|
"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>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>quantity</name>
|
<name>quantity</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -3684,6 +3705,48 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>generic_failure</name>
|
<name>generic_failure</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -3831,6 +3894,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
</children>
|
||||||
</folder_node>
|
</folder_node>
|
||||||
<concept_node>
|
<concept_node>
|
||||||
@@ -8641,6 +8725,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>partsqueue</name>
|
<name>partsqueue</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -17816,6 +17921,468 @@
|
|||||||
<folder_node>
|
<folder_node>
|
||||||
<name>labels</name>
|
<name>labels</name>
|
||||||
<children>
|
<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>
|
<concept_node>
|
||||||
<name>refreshallocations</name>
|
<name>refreshallocations</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -17837,6 +18404,153 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
</children>
|
||||||
</folder_node>
|
</folder_node>
|
||||||
</children>
|
</children>
|
||||||
@@ -20590,6 +21304,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>download</name>
|
<name>download</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -23009,6 +23744,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>view</name>
|
<name>view</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -23423,6 +24179,27 @@
|
|||||||
<folder_node>
|
<folder_node>
|
||||||
<name>validation</name>
|
<name>validation</name>
|
||||||
<children>
|
<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>
|
<concept_node>
|
||||||
<name>dateRangeExceeded</name>
|
<name>dateRangeExceeded</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -57452,6 +58229,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
<concept_node>
|
||||||
<name>work_in_progress_payables</name>
|
<name>work_in_progress_payables</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -57473,6 +58271,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</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>
|
</children>
|
||||||
</folder_node>
|
</folder_node>
|
||||||
</children>
|
</children>
|
||||||
|
|||||||
1189
client/package-lock.json
generated
1189
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:4000",
|
"proxy": "http://localhost:4000",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/analytics-browser": "^2.35.3",
|
"@amplitude/analytics-browser": "^2.38.0",
|
||||||
"@ant-design/pro-layout": "^7.22.6",
|
"@ant-design/pro-layout": "^7.22.6",
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -16,68 +16,68 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||||
"@firebase/analytics": "^0.10.19",
|
"@firebase/analytics": "^0.10.21",
|
||||||
"@firebase/app": "^0.14.8",
|
"@firebase/app": "^0.14.10",
|
||||||
"@firebase/auth": "^1.12.0",
|
"@firebase/auth": "^1.12.2",
|
||||||
"@firebase/firestore": "^4.11.0",
|
"@firebase/firestore": "^4.13.0",
|
||||||
"@firebase/messaging": "^0.12.22",
|
"@firebase/messaging": "^0.12.25",
|
||||||
"@jsreport/browser-client": "^3.1.0",
|
"@jsreport/browser-client": "^3.1.0",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@sentry/cli": "^3.2.2",
|
"@sentry/cli": "^3.3.5",
|
||||||
"@sentry/react": "^10.40.0",
|
"@sentry/react": "^10.47.0",
|
||||||
"@sentry/vite-plugin": "^4.9.1",
|
"@sentry/vite-plugin": "^4.9.1",
|
||||||
"@splitsoftware/splitio-react": "^2.6.1",
|
"@splitsoftware/splitio-react": "^2.6.1",
|
||||||
"@tanem/react-nprogress": "^5.0.63",
|
"@tanem/react-nprogress": "^5.0.63",
|
||||||
"antd": "^6.3.1",
|
"antd": "^6.3.5",
|
||||||
"apollo-link-logger": "^3.0.0",
|
"apollo-link-logger": "^3.0.0",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.14.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"css-box-model": "^1.2.1",
|
"css-box-model": "^1.2.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"dayjs-business-days2": "^1.3.2",
|
"dayjs-business-days2": "^1.3.3",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"env-cmd": "^11.0.0",
|
"env-cmd": "^11.0.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"graphql": "^16.13.0",
|
"graphql": "^16.13.2",
|
||||||
"graphql-ws": "^6.0.7",
|
"graphql-ws": "^6.0.8",
|
||||||
"i18next": "^25.8.13",
|
"i18next": "^25.10.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"immutability-helper": "^3.1.1",
|
"immutability-helper": "^3.1.1",
|
||||||
"libphonenumber-js": "^1.12.38",
|
"libphonenumber-js": "^1.12.41",
|
||||||
"lightningcss": "^1.31.1",
|
"lightningcss": "^1.32.0",
|
||||||
"logrocket": "^12.0.0",
|
"logrocket": "^12.1.0",
|
||||||
"markerjs2": "^2.32.7",
|
"markerjs2": "^2.32.7",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"normalize-url": "^8.1.1",
|
"normalize-url": "^8.1.1",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"phone": "^3.1.71",
|
"phone": "^3.1.71",
|
||||||
"posthog-js": "^1.355.0",
|
"posthog-js": "^1.364.4",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"query-string": "^9.3.1",
|
"query-string": "^9.3.1",
|
||||||
"raf-schd": "^4.0.3",
|
"raf-schd": "^4.0.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-big-calendar": "^1.19.4",
|
"react-big-calendar": "^1.19.4",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-cookie": "^8.0.1",
|
"react-cookie": "^8.1.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-grid-gallery": "^1.0.1",
|
"react-grid-gallery": "^1.0.1",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.3",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.6.6",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-image-lightbox": "^5.1.4",
|
"react-image-lightbox": "^5.1.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-number-format": "^5.4.3",
|
"react-number-format": "^5.4.5",
|
||||||
"react-popopo": "^2.1.9",
|
"react-popopo": "^2.1.9",
|
||||||
"react-product-fruits": "^2.2.62",
|
"react-product-fruits": "^2.2.62",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.2",
|
||||||
"react-sticky": "^6.0.3",
|
"react-sticky": "^6.0.3",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.3",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.8.1",
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"redux-actions": "^3.0.3",
|
"redux-actions": "^3.0.3",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
@@ -85,11 +85,11 @@
|
|||||||
"redux-state-sync": "^3.1.4",
|
"redux-state-sync": "^3.1.4",
|
||||||
"reselect": "^5.1.1",
|
"reselect": "^5.1.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.98.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"styled-components": "^6.3.11",
|
"styled-components": "^6.3.12",
|
||||||
"vite-plugin-ejs": "^1.7.0",
|
"vite-plugin-ejs": "^1.7.0",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
|
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
|
||||||
@@ -137,10 +137,10 @@
|
|||||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"@dotenvx/dotenvx": "^1.52.0",
|
"@dotenvx/dotenvx": "^1.59.1",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
@@ -150,27 +150,27 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"browserslist": "^4.28.1",
|
"browserslist": "^4.28.2",
|
||||||
"browserslist-to-esbuild": "^2.1.1",
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"memfs": "^4.56.10",
|
"memfs": "^4.57.1",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "^0.3.0",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"react-error-overlay": "^6.1.0",
|
"react-error-overlay": "^6.1.0",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"source-map-explorer": "^2.5.3",
|
"source-map-explorer": "^2.5.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-babel": "^1.5.1",
|
"vite-plugin-babel": "^1.6.0",
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
"vite-plugin-eslint": "^1.8.1",
|
||||||
"vite-plugin-node-polyfills": "^0.25.0",
|
"vite-plugin-node-polyfills": "^0.26.0",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-style-import": "^2.0.0",
|
"vite-plugin-style-import": "^2.0.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.1.2",
|
||||||
"workbox-window": "^7.4.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 { connect } from "react-redux";
|
||||||
|
import queryString from "query-string";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
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({});
|
const mapStateToProps = createStructuredSelector({});
|
||||||
|
|
||||||
@@ -9,8 +13,109 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
setRefundPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "refund_payment" }))
|
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 }) {
|
function Test({ setRefundPaymentContext, refundPaymentModal }) {
|
||||||
|
const search = queryString.parse(useLocation().search);
|
||||||
console.log("refundPaymentModal", refundPaymentModal);
|
console.log("refundPaymentModal", refundPaymentModal);
|
||||||
|
|
||||||
|
if (search.fixture === "commission-cut") {
|
||||||
|
return <CommissionCutHarness />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert } from "antd";
|
import { Alert } from "antd";
|
||||||
|
|
||||||
export default function AlertComponent(props) {
|
export default function AlertComponent({ title, message, ...props }) {
|
||||||
return <Alert {...props} />;
|
return <Alert {...props} title={title ?? message} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
|
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import BillFormContainer from "../bill-form/bill-form.container";
|
import BillFormContainer from "../bill-form/bill-form.container";
|
||||||
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
|
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
|
||||||
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
|
|||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
|
|
||||||
|
const details = buildBillUpdateAuditDetails({
|
||||||
|
originalBill: data?.bills_by_pk,
|
||||||
|
bill,
|
||||||
|
billlines
|
||||||
|
});
|
||||||
|
|
||||||
insertAuditTrail({
|
insertAuditTrail({
|
||||||
jobid: bill.jobid,
|
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
|
||||||
billid: search.billid,
|
billid: search.billid,
|
||||||
operation: AuditTrailMapping.billupdated(bill.invoice_number),
|
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
|
||||||
type: "billupdated"
|
type: "billupdated"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ function BillEnterAiScan({
|
|||||||
fileInputRef,
|
fileInputRef,
|
||||||
scanLoading,
|
scanLoading,
|
||||||
setScanLoading,
|
setScanLoading,
|
||||||
setIsAiScan
|
setIsAiScan,
|
||||||
|
setRawAIData
|
||||||
}) {
|
}) {
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -57,6 +58,7 @@ function BillEnterAiScan({
|
|||||||
}
|
}
|
||||||
setScanLoading(false);
|
setScanLoading(false);
|
||||||
|
|
||||||
|
setRawAIData(data.data);
|
||||||
// Update form with the extracted data
|
// Update form with the extracted data
|
||||||
if (data?.data?.billForm) {
|
if (data?.data?.billForm) {
|
||||||
form.setFieldsValue(data.data.billForm);
|
form.setFieldsValue(data.data.billForm);
|
||||||
@@ -147,6 +149,7 @@ function BillEnterAiScan({
|
|||||||
setScanLoading(false);
|
setScanLoading(false);
|
||||||
|
|
||||||
form.setFieldsValue(data.data.billForm);
|
form.setFieldsValue(data.data.billForm);
|
||||||
|
setRawAIData(data.data);
|
||||||
await form.validateFields(["billlines"], { recursive: true });
|
await form.validateFields(["billlines"], { recursive: true });
|
||||||
|
|
||||||
notification.success({
|
notification.success({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-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 _ from "lodash";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||||
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
||||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||||
|
import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
billEnterModal: selectBillEnterModal,
|
billEnterModal: selectBillEnterModal,
|
||||||
@@ -53,6 +54,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [scanLoading, setScanLoading] = useState(false);
|
const [scanLoading, setScanLoading] = useState(false);
|
||||||
const [isAiScan, setIsAiScan] = useState(false);
|
const [isAiScan, setIsAiScan] = useState(false);
|
||||||
|
const [rawAIData, setRawAIData] = useState(null);
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
@@ -387,6 +389,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
billlines: []
|
billlines: []
|
||||||
});
|
});
|
||||||
setIsAiScan(false);
|
setIsAiScan(false);
|
||||||
|
setRawAIData(null);
|
||||||
// form.resetFields();
|
// form.resetFields();
|
||||||
} else {
|
} else {
|
||||||
toggleModalVisible();
|
toggleModalVisible();
|
||||||
@@ -404,6 +407,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
}
|
}
|
||||||
setScanLoading(false);
|
setScanLoading(false);
|
||||||
setIsAiScan(false);
|
setIsAiScan(false);
|
||||||
|
setRawAIData(null);
|
||||||
toggleModalVisible();
|
toggleModalVisible();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -429,6 +433,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
}
|
}
|
||||||
setScanLoading(false);
|
setScanLoading(false);
|
||||||
setIsAiScan(false);
|
setIsAiScan(false);
|
||||||
|
setRawAIData(null);
|
||||||
}
|
}
|
||||||
}, [billEnterModal.open, form, formValues]);
|
}, [billEnterModal.open, form, formValues]);
|
||||||
|
|
||||||
@@ -456,6 +461,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
scanLoading={scanLoading}
|
scanLoading={scanLoading}
|
||||||
setScanLoading={setScanLoading}
|
setScanLoading={setScanLoading}
|
||||||
setIsAiScan={setIsAiScan}
|
setIsAiScan={setIsAiScan}
|
||||||
|
setRawAIData={setRawAIData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -471,26 +477,34 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
<Space>
|
<Space orientation="vertical">
|
||||||
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
|
{isAiScan && (
|
||||||
{t("bills.labels.generatepartslabel")}
|
<>
|
||||||
</Checkbox>
|
<BillAiFeedback billForm={form} rawAIData={rawAIData} />
|
||||||
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
|
<Divider orientation="horizontal" />
|
||||||
<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 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>
|
</Space>
|
||||||
}
|
}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function BillFormComponent({
|
|||||||
const [discount, setDiscount] = useState(0);
|
const [discount, setDiscount] = useState(0);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const jobIdFormWatch = Form.useWatch("jobid", form);
|
const jobIdFormWatch = Form.useWatch("jobid", form);
|
||||||
|
const vendorIdFormWatch = Form.useWatch("vendorid", form);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
treatments: { Extended_Bill_Posting, ClosingPeriod }
|
treatments: { Extended_Bill_Posting, ClosingPeriod }
|
||||||
@@ -118,6 +119,7 @@ export function BillFormComponent({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
form,
|
form,
|
||||||
|
vendorIdFormWatch,
|
||||||
billEdit,
|
billEdit,
|
||||||
loadOutstandingReturns,
|
loadOutstandingReturns,
|
||||||
loadInventory,
|
loadInventory,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function BillEnterModalLinesComponent({
|
|||||||
|
|
||||||
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||||
const autofillActualCost = (index) => {
|
const autofillActualCost = (index) => {
|
||||||
|
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
|
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
|
||||||
const actualRaw = form.getFieldValue(["billlines", index, "actual_cost"]);
|
const actualRaw = form.getFieldValue(["billlines", index, "actual_cost"]);
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
|
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
authLevel: selectAuthLevel
|
authLevel: selectAuthLevel
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = () => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton);
|
export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton);
|
||||||
|
|
||||||
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
|
export function BillMarkForReexportButton({ bodyshop, authLevel, bill, insertAuditTrail }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
@@ -47,6 +49,12 @@ export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("bills.successes.reexport")
|
title: t("bills.successes.reexport")
|
||||||
});
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: bill.jobid,
|
||||||
|
billid: bill.id,
|
||||||
|
operation: AuditTrailMapping.billmarkforreexport(bill.invoice_number),
|
||||||
|
type: "billmarkforreexport"
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("bills.errors.saving", {
|
title: t("bills.errors.saving", {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
|
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
//currentUser: selectCurrentUser
|
//currentUser: selectCurrentUser
|
||||||
@@ -156,104 +157,127 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
|
|||||||
joblines: {
|
joblines: {
|
||||||
data: billingLines
|
data: billingLines
|
||||||
},
|
},
|
||||||
parts_tax_rates: {
|
...InstanceRenderManager({
|
||||||
PAA: {
|
imex: {
|
||||||
prt_type: "PAA",
|
parts_tax_rates: {
|
||||||
prt_discp: 0,
|
PAA: {
|
||||||
prt_mktyp: false,
|
prt_type: "PAA",
|
||||||
prt_mkupp: 0,
|
prt_discp: 0,
|
||||||
prt_tax_in: true,
|
prt_mktyp: false,
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAC: {
|
||||||
|
prt_type: "PAC",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAL: {
|
||||||
|
prt_type: "PAL",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAM: {
|
||||||
|
prt_type: "PAM",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAN: {
|
||||||
|
prt_type: "PAN",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAR: {
|
||||||
|
prt_type: "PAR",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
PAS: {
|
||||||
|
prt_type: "PAS",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
CCDR: {
|
||||||
|
prt_type: "CCDR",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
CCF: {
|
||||||
|
prt_type: "CCF",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
CCM: {
|
||||||
|
prt_type: "CCM",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
CCC: {
|
||||||
|
prt_type: "CCC",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
},
|
||||||
|
CCD: {
|
||||||
|
prt_type: "CCD",
|
||||||
|
prt_discp: 0,
|
||||||
|
prt_mktyp: false,
|
||||||
|
prt_mkupp: 0,
|
||||||
|
prt_tax_in: true,
|
||||||
|
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
PAC: {
|
rome: {
|
||||||
prt_type: "PAC",
|
cieca_pft: {
|
||||||
prt_discp: 0,
|
...bodyshop.md_responsibility_centers.taxes.tax_ty1,
|
||||||
prt_mktyp: false,
|
...bodyshop.md_responsibility_centers.taxes.tax_ty2,
|
||||||
prt_mkupp: 0,
|
...bodyshop.md_responsibility_centers.taxes.tax_ty3,
|
||||||
prt_tax_in: true,
|
...bodyshop.md_responsibility_centers.taxes.tax_ty4,
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
...bodyshop.md_responsibility_centers.taxes.tax_ty5
|
||||||
},
|
},
|
||||||
PAL: {
|
materials: bodyshop.md_responsibility_centers.cieca_pfm,
|
||||||
prt_type: "PAL",
|
cieca_pfl: bodyshop.md_responsibility_centers.cieca_pfl,
|
||||||
prt_discp: 0,
|
parts_tax_rates: bodyshop.md_responsibility_centers.parts_tax_rates,
|
||||||
prt_mktyp: false,
|
tax_tow_rt: bodyshop.md_responsibility_centers.tax_tow_rt,
|
||||||
prt_mkupp: 0,
|
tax_str_rt: bodyshop.md_responsibility_centers.tax_str_rt,
|
||||||
prt_tax_in: true,
|
tax_paint_mat_rt: bodyshop.md_responsibility_centers.tax_paint_mat_rt,
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
tax_shop_mat_rt: bodyshop.md_responsibility_centers.tax_shop_mat_rt,
|
||||||
},
|
tax_sub_rt: bodyshop.md_responsibility_centers.tax_sub_rt,
|
||||||
PAM: {
|
tax_lbr_rt: bodyshop.md_responsibility_centers.tax_lbr_rt,
|
||||||
prt_type: "PAM",
|
tax_levies_rt: bodyshop.md_responsibility_centers.tax_levies_rt
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
PAN: {
|
|
||||||
prt_type: "PAN",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
PAR: {
|
|
||||||
prt_type: "PAR",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
PAS: {
|
|
||||||
prt_type: "PAS",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
CCDR: {
|
|
||||||
prt_type: "CCDR",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
CCF: {
|
|
||||||
prt_type: "CCF",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
CCM: {
|
|
||||||
prt_type: "CCM",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
CCC: {
|
|
||||||
prt_type: "CCC",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
},
|
|
||||||
CCD: {
|
|
||||||
prt_type: "CCD",
|
|
||||||
prt_discp: 0,
|
|
||||||
prt_mktyp: false,
|
|
||||||
prt_mkupp: 0,
|
|
||||||
prt_tax_in: true,
|
|
||||||
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentUser?.email) {
|
if (currentUser?.email) {
|
||||||
@@ -287,7 +311,7 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("jobs.successes.created"),
|
title: t("jobs.successes.created"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
history.push(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`);
|
history(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
|
|||||||
* RR-specific DMS Allocations Summary
|
* RR-specific DMS Allocations Summary
|
||||||
* Focused on what we actually send to RR:
|
* Focused on what we actually send to RR:
|
||||||
* - ROGOG (split by taxable / non-taxable segments)
|
* - ROGOG (split by taxable / non-taxable segments)
|
||||||
* - ROLABOR shell
|
* - ROLABOR labor rows with bill hours / rates
|
||||||
*
|
*
|
||||||
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
||||||
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
||||||
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
const rolaborRows = useMemo(() => {
|
const rolaborRows = useMemo(() => {
|
||||||
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
||||||
|
|
||||||
return rolaborPreview.ops.map((op, idx) => {
|
return rolaborPreview.ops
|
||||||
const rowOpCode = opCode || op.opCode;
|
.filter((op) =>
|
||||||
|
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
|
||||||
|
.map((value) => Number.parseFloat(value ?? "0"))
|
||||||
|
.some((value) => !Number.isNaN(value) && value !== 0)
|
||||||
|
)
|
||||||
|
.map((op, idx) => {
|
||||||
|
const rowOpCode = opCode || op.opCode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `${op.jobNo}-${idx}`,
|
key: `${op.jobNo}-${idx}`,
|
||||||
opCode: rowOpCode,
|
opCode: rowOpCode,
|
||||||
jobNo: op.jobNo,
|
jobNo: op.jobNo,
|
||||||
custPayTypeFlag: op.custPayTypeFlag,
|
custPayTypeFlag: op.custPayTypeFlag,
|
||||||
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
||||||
payType: op.bill?.payType,
|
payType: op.bill?.payType,
|
||||||
amtType: op.amount?.amtType,
|
jobTotalHrs: op.bill?.jobTotalHrs,
|
||||||
custPrice: op.amount?.custPrice,
|
billTime: op.bill?.billTime,
|
||||||
totalAmt: op.amount?.totalAmt
|
billRate: op.bill?.billRate,
|
||||||
};
|
amtType: op.amount?.amtType,
|
||||||
});
|
custPrice: op.amount?.custPrice,
|
||||||
|
totalAmt: op.amount?.totalAmt
|
||||||
|
};
|
||||||
|
});
|
||||||
}, [rolaborPreview, opCode]);
|
}, [rolaborPreview, opCode]);
|
||||||
|
|
||||||
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
||||||
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
||||||
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
||||||
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
||||||
|
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
|
||||||
|
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
|
||||||
|
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
|
||||||
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
||||||
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
||||||
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
||||||
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||||
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
|
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
|
||||||
|
job's labor lines.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={rolaborColumns}
|
columns={rolaborColumns}
|
||||||
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
|
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
|
||||||
rowKey="key"
|
rowKey="key"
|
||||||
dataSource={rolaborRows}
|
dataSource={rolaborRows}
|
||||||
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
|||||||
const emailsToMenu = {
|
const emailsToMenu = {
|
||||||
items: [
|
items: [
|
||||||
...bodyshop.employees
|
...bodyshop.employees
|
||||||
.filter((e) => e.user_email)
|
.filter((e) => e.user_email && e.active === true)
|
||||||
.map((e, idx) => ({
|
.map((e, idx) => ({
|
||||||
key: idx,
|
key: idx,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
|||||||
const menuCC = {
|
const menuCC = {
|
||||||
items: [
|
items: [
|
||||||
...bodyshop.employees
|
...bodyshop.employees
|
||||||
.filter((e) => e.user_email)
|
.filter((e) => e.user_email && e.active === true)
|
||||||
.map((e, idx) => ({
|
.map((e, idx) => ({
|
||||||
key: idx,
|
key: idx,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
if (!e.target) return;
|
||||||
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
|
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
|
||||||
if (bottom && !hasEverScrolledToBottom) {
|
if (bottom && !hasEverScrolledToBottom) {
|
||||||
setHasEverScrolledToBottom(true);
|
setHasEverScrolledToBottom(true);
|
||||||
@@ -36,7 +37,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleScroll({ target: markdownCardRef.current });
|
if (markdownCardRef.current) {
|
||||||
|
handleScroll({ target: markdownCardRef.current });
|
||||||
|
}
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
const handleChange = useCallback(() => {
|
const handleChange = useCallback(() => {
|
||||||
|
|||||||
@@ -4,20 +4,203 @@ import AlertComponent from "../alert/alert.component";
|
|||||||
import "./form-fields-changed.styles.scss";
|
import "./form-fields-changed.styles.scss";
|
||||||
import Prompt from "../../utils/prompt";
|
import Prompt from "../../utils/prompt";
|
||||||
|
|
||||||
export default function FormsFieldChanged({ form, skipPrompt }) {
|
export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, onReset, onDirtyChange }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const normalizeNamePath = (namePath) => (Array.isArray(namePath) ? namePath.filter((part) => part !== undefined) : [namePath]);
|
||||||
|
|
||||||
|
const getFieldIdCandidates = (namePath) => {
|
||||||
|
const normalizedNamePath = normalizeNamePath(namePath).map((part) => String(part));
|
||||||
|
const underscoreId = normalizedNamePath.join("_");
|
||||||
|
const dashId = normalizedNamePath.join("-");
|
||||||
|
const dotName = normalizedNamePath.join(".");
|
||||||
|
|
||||||
|
return [underscoreId, dashId, dotName].filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFormMeta = () => {
|
||||||
|
const fieldMeta = form.getFieldsError().map(({ name }) => ({
|
||||||
|
name,
|
||||||
|
touched: false,
|
||||||
|
validating: false,
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fieldMeta.length > 0) {
|
||||||
|
form.setFields(fieldMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDirtyChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
form.resetFields();
|
if (onReset) {
|
||||||
|
onReset();
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
clearFormMeta();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldDomNode = (namePath) => {
|
||||||
|
const fieldInstance = form.getFieldInstance?.(namePath);
|
||||||
|
const fieldIdCandidates = getFieldIdCandidates(namePath);
|
||||||
|
const domCandidates = [
|
||||||
|
fieldInstance?.nativeElement,
|
||||||
|
fieldInstance?.input,
|
||||||
|
fieldInstance?.resizableTextArea?.textArea,
|
||||||
|
fieldInstance
|
||||||
|
];
|
||||||
|
|
||||||
|
fieldIdCandidates.forEach((fieldId) => {
|
||||||
|
const escapedFieldId = CSS.escape(fieldId);
|
||||||
|
const directNode = document.getElementById(fieldId) || document.querySelector(`#${escapedFieldId}`);
|
||||||
|
const labelNode = document.querySelector(`label[for="${escapedFieldId}"]`);
|
||||||
|
const namedNode = document.querySelector(`[name="${escapedFieldId}"]`);
|
||||||
|
const formItemNode =
|
||||||
|
directNode?.closest?.(".ant-form-item") ||
|
||||||
|
labelNode?.closest?.(".ant-form-item") ||
|
||||||
|
namedNode?.closest?.(".ant-form-item");
|
||||||
|
|
||||||
|
domCandidates.push(directNode);
|
||||||
|
domCandidates.push(namedNode);
|
||||||
|
domCandidates.push(formItemNode);
|
||||||
|
domCandidates.push(formItemNode?.querySelector?.("input, textarea, select, .ant-select-selector"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return domCandidates.find((candidate) => candidate instanceof HTMLElement) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForAnimationFrames = (frameCount = 1) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
let remainingFrames = frameCount;
|
||||||
|
const nextFrame = () => {
|
||||||
|
if (remainingFrames <= 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remainingFrames -= 1;
|
||||||
|
window.requestAnimationFrame(nextFrame);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(nextFrame);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFieldOwningTabMeta = (namePath) => {
|
||||||
|
const fieldDomNode = getFieldDomNode(namePath);
|
||||||
|
const owningTabPane = fieldDomNode?.closest?.(".ant-tabs-tabpane");
|
||||||
|
const paneId = owningTabPane?.getAttribute?.("id") || null;
|
||||||
|
const owningTabButton = paneId
|
||||||
|
? document.querySelector(`[role="tab"][aria-controls="${paneId.replace(/"/g, '\\"')}"]`)
|
||||||
|
: null;
|
||||||
|
const tabLabel = owningTabButton?.textContent?.trim() || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
owningTabPane,
|
||||||
|
owningTabButton,
|
||||||
|
tabLabel
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFieldOwningTab = async (namePath) => {
|
||||||
|
const { owningTabPane, owningTabButton } = getFieldOwningTabMeta(namePath);
|
||||||
|
if (!owningTabPane || owningTabPane.classList.contains("ant-tabs-tabpane-active")) return false;
|
||||||
|
|
||||||
|
if (!(owningTabButton instanceof HTMLElement)) return false;
|
||||||
|
|
||||||
|
owningTabButton.click();
|
||||||
|
|
||||||
|
for (let index = 0; index < 24; index += 1) {
|
||||||
|
await waitForAnimationFrames();
|
||||||
|
if (owningTabPane.classList.contains("ant-tabs-tabpane-active")) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return owningTabPane.classList.contains("ant-tabs-tabpane-active");
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToErrorField = (namePath) => {
|
||||||
|
const normalizedNamePath = normalizeNamePath(namePath);
|
||||||
|
if (!normalizedNamePath.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
form.scrollToField(normalizedNamePath, {
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
focus: true
|
||||||
|
});
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const fallbackNode = getFieldDomNode(normalizedNamePath);
|
||||||
|
fallbackNode?.focus?.();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
const fallbackTarget = document.getElementById(normalizedNamePath[0]?.toString?.() ?? "");
|
||||||
|
fallbackTarget?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleErrorClick = async (namePath) => {
|
||||||
|
const normalizedNamePath = normalizeNamePath(namePath);
|
||||||
|
if (!normalizedNamePath.length) return;
|
||||||
|
|
||||||
|
const switchedTab = await openFieldOwningTab(normalizedNamePath);
|
||||||
|
if (!switchedTab) {
|
||||||
|
const navigationDelayMs = onErrorNavigate?.(normalizedNamePath) ?? 0;
|
||||||
|
if (navigationDelayMs > 0) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
scrollToErrorField(normalizedNamePath);
|
||||||
|
});
|
||||||
|
}, navigationDelayMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForAnimationFrames(switchedTab ? 2 : 1);
|
||||||
|
scrollToErrorField(normalizedNamePath);
|
||||||
};
|
};
|
||||||
//if (!form.isFieldsTouched()) return <></>;
|
//if (!form.isFieldsTouched()) return <></>;
|
||||||
return (
|
return (
|
||||||
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}>
|
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}>
|
||||||
{() => {
|
{() => {
|
||||||
const errors = form.getFieldsError().filter((e) => e.errors.length > 0);
|
const errors = form
|
||||||
|
.getFieldsError()
|
||||||
|
.filter((fieldError) => fieldError.errors.length > 0)
|
||||||
|
.flatMap((fieldError) => {
|
||||||
|
const tabMeta = getFieldOwningTabMeta(fieldError.name);
|
||||||
|
|
||||||
|
return fieldError.errors.map((errorMessage, errorIndex) => ({
|
||||||
|
key: `${(fieldError.name || []).join(".")}-${errorIndex}-${errorMessage}`,
|
||||||
|
message: errorMessage,
|
||||||
|
namePath: fieldError.name,
|
||||||
|
tabLabel: tabMeta.tabLabel
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedErrors = errors.reduce((groups, error) => {
|
||||||
|
const groupKey = error.tabLabel || "__ungrouped__";
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = {
|
||||||
|
key: groupKey,
|
||||||
|
label: error.tabLabel,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groups[groupKey].errors.push(error);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
const errorGroups = Object.values(groupedErrors);
|
||||||
|
const hasTabbedErrorGroups = errorGroups.some((group) => Boolean(group.label));
|
||||||
|
|
||||||
if (form.isFieldsTouched())
|
if (form.isFieldsTouched())
|
||||||
return (
|
return (
|
||||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
<Space orientation="vertical" style={{ width: "100%", marginBottom: 10 }}>
|
||||||
<Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
|
<Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
|
||||||
<AlertComponent
|
<AlertComponent
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -39,10 +222,35 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
|
|||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<AlertComponent
|
<AlertComponent
|
||||||
type="error"
|
type="error"
|
||||||
message={t("general.labels.validationerror")}
|
title={t("general.labels.validationerror")}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div className="form-fields-changed__error-groups">
|
||||||
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
|
{errorGroups.map((group) => (
|
||||||
|
<div key={group.key} className="form-fields-changed__error-group">
|
||||||
|
{hasTabbedErrorGroups && group.label ? (
|
||||||
|
<div className="form-fields-changed__error-group-title">{group.label}</div>
|
||||||
|
) : null}
|
||||||
|
<ul className="form-fields-changed__error-list">
|
||||||
|
{group.errors.map((error) => (
|
||||||
|
<li key={error.key}>
|
||||||
|
{Array.isArray(error.namePath) && error.namePath.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-fields-changed__error-link"
|
||||||
|
onClick={() => {
|
||||||
|
handleErrorClick(error.namePath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
error.message
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
showIcon
|
showIcon
|
||||||
|
|||||||
@@ -4,4 +4,47 @@
|
|||||||
min-height: unset !important;
|
min-height: unset !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__error-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error-group-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error-link {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: color-mix(in srgb, var(--ant-color-error) 82%, var(--ant-color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--ant-color-error) 32%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,88 @@
|
|||||||
import { Input } from "antd";
|
import { PhoneFilled } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Space } from "antd";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
|
import { forwardRef, useMemo, useState } from "react";
|
||||||
import "./phone-form-item.styles.scss";
|
import "./phone-form-item.styles.scss";
|
||||||
|
|
||||||
function FormItemPhone({ ref, ...props }) {
|
/**
|
||||||
return <Input ref={ref} {...props} />;
|
* Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a
|
||||||
}
|
* national format (e.g., (123) 456-7890 for US/CA). If the input is not a valid phone number, it will be returned as-is.
|
||||||
|
* @param value
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
const formatPhoneDisplayValue = (value) => {
|
||||||
|
if (!value) return value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPhone = parsePhoneNumber(value, "CA");
|
||||||
|
return parsedPhone?.isValid() ? parsedPhone.formatNational() : value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a "tel:" URL for a phone number if it's valid. If the input value is a valid phone number, it will return a
|
||||||
|
* URL in the format "tel:+1234567890". If the input is not a valid phone number, it will attempt to trim whitespace and
|
||||||
|
* return a "tel:" URL with the raw value, or null if the trimmed value is empty.
|
||||||
|
* @param value
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
const getPhoneActionHref = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPhone = parsePhoneNumber(value, "CA");
|
||||||
|
if (parsedPhone?.isValid()) return `tel:${parsedPhone.number}`;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the raw value below.
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedValue = String(value).trim();
|
||||||
|
return trimmedValue ? `tel:${trimmedValue}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemPhone = forwardRef(function FormItemPhone(
|
||||||
|
{ formatDisplayOnly = false, showPhoneAction = false, value, onBlur, onFocus, ...props },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (!formatDisplayOnly || isFocused) return value;
|
||||||
|
return formatPhoneDisplayValue(value);
|
||||||
|
}, [formatDisplayOnly, isFocused, value]);
|
||||||
|
const phoneActionHref = useMemo(() => (showPhoneAction ? getPhoneActionHref(value) : null), [showPhoneAction, value]);
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
value={displayValue}
|
||||||
|
onFocus={(event) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(event);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onBlur?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showPhoneAction) return input;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
{input}
|
||||||
|
{phoneActionHref ? (
|
||||||
|
<Button icon={<PhoneFilled />} href={phoneActionHref} />
|
||||||
|
) : (
|
||||||
|
<Button icon={<PhoneFilled />} disabled />
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default FormItemPhone;
|
export default FormItemPhone;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
||||||
if (!value) return null;
|
if (value === null || value === undefined || value === "") return null;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "employee": {
|
case "employee": {
|
||||||
const emp = bodyshop.employees.find((e) => e.id === value);
|
const emp = bodyshop.employees.find((e) => e.id === value);
|
||||||
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
|||||||
|
|
||||||
case "text":
|
case "text":
|
||||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||||
case "currency":
|
case "currency": {
|
||||||
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
|
const numericValue = toFiniteNumber(value);
|
||||||
|
|
||||||
|
if (numericValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { LinkOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Space } from "antd";
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
|
||||||
|
const HAS_URL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
||||||
|
const LOCALHOST_OR_IP_REGEX = /^(localhost|127(?:\.\d{1,3}){3}|\d{1,3}(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i;
|
||||||
|
|
||||||
|
const getUrlActionHref = (value) => {
|
||||||
|
const trimmedValue = String(value ?? "").trim();
|
||||||
|
if (!trimmedValue) return null;
|
||||||
|
|
||||||
|
if (HAS_URL_PROTOCOL_REGEX.test(trimmedValue)) return trimmedValue;
|
||||||
|
if (trimmedValue.startsWith("//")) return `https:${trimmedValue}`;
|
||||||
|
if (LOCALHOST_OR_IP_REGEX.test(trimmedValue)) return `http://${trimmedValue}`;
|
||||||
|
|
||||||
|
return `https://${trimmedValue}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemUrl = forwardRef(function FormItemUrl({ value, defaultValue, ...props }, ref) {
|
||||||
|
const urlActionHref = useMemo(() => getUrlActionHref(value ?? defaultValue), [defaultValue, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
<Input ref={ref} {...props} value={value} defaultValue={defaultValue} />
|
||||||
|
{urlActionHref ? (
|
||||||
|
<Button icon={<LinkOutlined />} href={urlActionHref} target="_blank" rel="noopener noreferrer" />
|
||||||
|
) : (
|
||||||
|
<Button icon={<LinkOutlined />} disabled />
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FormItemUrl;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Normalize Form Item List Titles
|
||||||
|
* @param value
|
||||||
|
* @returns {*|string}
|
||||||
|
*/
|
||||||
|
const normalizeFormListTitleValue = (value) => {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeFormListTitleValue(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Form Listem Item Title
|
||||||
|
* @param fallbackLabel
|
||||||
|
* @param index
|
||||||
|
* @param candidates
|
||||||
|
* @returns {*|string}
|
||||||
|
*/
|
||||||
|
export function getFormListItemTitle(fallbackLabel, index, ...candidates) {
|
||||||
|
const title = candidates.map((candidate) => normalizeFormListTitleValue(candidate)).find(Boolean);
|
||||||
|
|
||||||
|
return title || `${fallbackLabel} ${index + 1}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DownOutlined, UpOutlined } from "@ant-design/icons";
|
import { DownOutlined, UpOutlined } from "@ant-design/icons";
|
||||||
import { Space } from "antd";
|
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 upDisabled = index === 0;
|
||||||
const downDisabled = index === total - 1;
|
const downDisabled = index === total - 1;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space orientation="vertical">
|
<Space orientation={orientation}>
|
||||||
<UpOutlined disabled={upDisabled} onClick={handleUp} />
|
<UpOutlined disabled={upDisabled} onClick={handleUp} />
|
||||||
<DownOutlined disabled={downDisabled} onClick={handleDown} />
|
<DownOutlined disabled={downDisabled} onClick={handleDown} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { FaTasks } from "react-icons/fa";
|
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 dayjs from "../../utils/day";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
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 JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component.jsx";
|
||||||
|
|
||||||
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
||||||
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
||||||
@@ -66,7 +67,8 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
jobRO: selectJobReadOnly,
|
jobRO: selectJobReadOnly,
|
||||||
technician: selectTechnician,
|
technician: selectTechnician,
|
||||||
isPartsEntry: selectIsPartsEntry
|
isPartsEntry: selectIsPartsEntry,
|
||||||
|
authLevel: selectAuthLevel
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
@@ -94,7 +96,8 @@ export function JobLinesComponent({
|
|||||||
setTaskUpsertContext,
|
setTaskUpsertContext,
|
||||||
billsQuery,
|
billsQuery,
|
||||||
handlePartsOrderOnRowClick,
|
handlePartsOrderOnRowClick,
|
||||||
isPartsEntry
|
isPartsEntry,
|
||||||
|
authLevel
|
||||||
}) {
|
}) {
|
||||||
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
||||||
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
||||||
@@ -386,18 +389,20 @@ export function JobLinesComponent({
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
render: (text, record) => (
|
render: (text, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
{(record.manual_line || jobIsPrivate) &&
|
||||||
<Button
|
!technician &&
|
||||||
disabled={jobRO}
|
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
onClick={() => {
|
<Button
|
||||||
setJobLineEditContext({
|
disabled={jobRO}
|
||||||
actions: { refetch: refetch, submit: form && form.submit },
|
onClick={() => {
|
||||||
context: { ...record, jobid: job.id }
|
setJobLineEditContext({
|
||||||
});
|
actions: { refetch: refetch, submit: form && form.submit },
|
||||||
}}
|
context: { ...record, jobid: job.id }
|
||||||
icon={<EditFilled />}
|
});
|
||||||
/>
|
}}
|
||||||
)}
|
icon={<EditFilled />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
title={t("tasks.buttons.create")}
|
title={t("tasks.buttons.create")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -410,29 +415,30 @@ export function JobLinesComponent({
|
|||||||
}}
|
}}
|
||||||
icon={<FaTasks />}
|
icon={<FaTasks />}
|
||||||
/>
|
/>
|
||||||
|
{(record.manual_line || jobIsPrivate) &&
|
||||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
!technician &&
|
||||||
<Button
|
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
disabled={jobRO}
|
<Button
|
||||||
onClick={async () => {
|
disabled={jobRO}
|
||||||
await deleteJobLine({
|
onClick={async () => {
|
||||||
variables: { joblineId: record.id },
|
await deleteJobLine({
|
||||||
update(cache) {
|
variables: { joblineId: record.id },
|
||||||
cache.modify({
|
update(cache) {
|
||||||
fields: {
|
cache.modify({
|
||||||
joblines(existingJobLines, { readField }) {
|
fields: {
|
||||||
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
joblines(existingJobLines, { readField }) {
|
||||||
|
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
await axios.post("/job/totalsssu", { id: job.id });
|
||||||
await axios.post("/job/totalsssu", { id: job.id });
|
if (refetch) refetch();
|
||||||
if (refetch) refetch();
|
}}
|
||||||
}}
|
icon={<DeleteFilled />}
|
||||||
icon={<DeleteFilled />}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -657,7 +663,7 @@ export function JobLinesComponent({
|
|||||||
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
{!isPartsEntry && (
|
{!isPartsEntry && HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
<Button
|
<Button
|
||||||
disabled={jobRO || technician}
|
disabled={jobRO || technician}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
|
|||||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||||
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
|
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
|
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
jobLineEditModal: selectJobLineEditModal,
|
jobLineEditModal: selectJobLineEditModal,
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
|
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||||
});
|
});
|
||||||
|
|
||||||
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
|
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
|
||||||
const {
|
const {
|
||||||
treatments: { CriticalPartsScanning }
|
treatments: { CriticalPartsScanning }
|
||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.successes.created")
|
title: t("joblines.successes.created")
|
||||||
});
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: jobLineEditModal.context.jobid,
|
||||||
|
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
|
||||||
|
type: "jobmanuallineinsert"
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("joblines.errors.creating", {
|
title: t("joblines.errors.creating", {
|
||||||
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.successes.updated")
|
title: t("joblines.successes.updated")
|
||||||
});
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: jobLineEditModal.context.jobid,
|
||||||
|
operation: AuditTrailMapping.joblineupdate(
|
||||||
|
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
|
||||||
|
buildJobLineUpdateAuditDetails({
|
||||||
|
originalLine: jobLineEditModal.context,
|
||||||
|
values
|
||||||
|
})
|
||||||
|
),
|
||||||
|
type: "joblineupdate"
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.errors.updating", {
|
title: t("joblines.errors.updating", {
|
||||||
|
|||||||
@@ -144,18 +144,11 @@ export default function JobTotalsTableLabor({ job }) {
|
|||||||
{t("jobs.labels.mapa")}
|
{t("jobs.labels.mapa")}
|
||||||
{InstanceRenderManager({
|
{InstanceRenderManager({
|
||||||
imex:
|
imex:
|
||||||
job.materials?.mapa &&
|
(job.materials?.mapa ?? job.materials?.MAPA)?.cal_maxdlr > 0 &&
|
||||||
job.materials.mapa.cal_maxdlr &&
|
t("jobs.labels.threshhold", { amount: (job.materials.mapa ?? job.materials.MAPA).cal_maxdlr }),
|
||||||
job.materials.mapa.cal_maxdlr > 0 &&
|
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.mapa.cal_maxdlr
|
|
||||||
}),
|
|
||||||
rome:
|
rome:
|
||||||
job.materials?.MAPA &&
|
job.materials?.MAPA?.cal_maxdlr !== undefined &&
|
||||||
job.materials.MAPA.cal_maxdlr !== undefined &&
|
t("jobs.labels.threshhold", { amount: job.materials.MAPA.cal_maxdlr })
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.MAPA.cal_maxdlr
|
|
||||||
})
|
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
</ResponsiveTable.Summary.Cell>
|
</ResponsiveTable.Summary.Cell>
|
||||||
@@ -190,18 +183,11 @@ export default function JobTotalsTableLabor({ job }) {
|
|||||||
{t("jobs.labels.mash")}
|
{t("jobs.labels.mash")}
|
||||||
{InstanceRenderManager({
|
{InstanceRenderManager({
|
||||||
imex:
|
imex:
|
||||||
job.materials?.mash &&
|
(job.materials?.mash ?? job.materials?.MASH)?.cal_maxdlr > 0 &&
|
||||||
job.materials.mash.cal_maxdlr &&
|
t("jobs.labels.threshhold", { amount: (job.materials.mash ?? job.materials.MASH).cal_maxdlr }),
|
||||||
job.materials.mash.cal_maxdlr > 0 &&
|
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.mash.cal_maxdlr
|
|
||||||
}),
|
|
||||||
rome:
|
rome:
|
||||||
job.materials?.MASH &&
|
job.materials?.MASH?.cal_maxdlr !== undefined &&
|
||||||
job.materials.MASH.cal_maxdlr !== undefined &&
|
t("jobs.labels.threshhold", { amount: job.materials.MASH.cal_maxdlr })
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.MASH.cal_maxdlr
|
|
||||||
})
|
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
</ResponsiveTable.Summary.Cell>
|
</ResponsiveTable.Summary.Cell>
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export function JobsAdminClass({ bodyshop, job }) {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Popconfirm title={t("jobs.labels.changeclass")} onConfirm={() => form.submit()}>
|
<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>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<div>{t("jobs.labels.associationwarning")}</div>
|
<div>{t("jobs.labels.associationwarning")}</div>
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<div>{t("jobs.labels.associationwarning")}</div>
|
<div>{t("jobs.labels.associationwarning")}</div>
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
|||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: "children",
|
optionFilterProp: "children",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) =>
|
||||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||||
}}
|
}}
|
||||||
disabled={jobRO}
|
disabled={jobRO}
|
||||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||||
@@ -166,7 +166,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
|||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: "children",
|
optionFilterProp: "children",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) =>
|
||||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||||
}}
|
}}
|
||||||
disabled={jobRO}
|
disabled={jobRO}
|
||||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
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
|
technician: selectTechnician
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
|
||||||
|
|
||||||
export function PayrollLaborAllocationsTable({
|
export function PayrollLaborAllocationsTable({
|
||||||
jobId,
|
jobId,
|
||||||
joblines,
|
joblines,
|
||||||
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
|
|||||||
});
|
});
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTotals = async () => {
|
||||||
async function CalculateTotals() {
|
try {
|
||||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
const { data } = await axios.post("/payroll/calculatelabor", {
|
||||||
jobid: jobId
|
jobid: jobId
|
||||||
});
|
});
|
||||||
setTotals(data);
|
setTotals(data);
|
||||||
|
} catch (error) {
|
||||||
|
setTotals([]);
|
||||||
|
notification.error({
|
||||||
|
title: getRequestErrorMessage(error)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!!joblines && !!timetickets && !!bodyshop) {
|
if (!!joblines && !!timetickets && !!bodyshop) {
|
||||||
CalculateTotals();
|
loadTotals();
|
||||||
}
|
}
|
||||||
if (!jobId) setTotals([]);
|
if (!jobId) setTotals([]);
|
||||||
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
|
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
|
||||||
@@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({
|
|||||||
<Button
|
<Button
|
||||||
disabled={!hasTimeTicketAccess}
|
disabled={!hasTimeTicketAccess}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const response = await axios.post("/payroll/payall", {
|
try {
|
||||||
jobid: jobId
|
const response = await axios.post("/payroll/payall", {
|
||||||
});
|
jobid: jobId
|
||||||
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
if (response.data.success !== false) {
|
if (response.data.success !== false) {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("timetickets.successes.payall")
|
title: t("timetickets.successes.payall")
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
notification.error({
|
||||||
|
title: t("timetickets.errors.payall", {
|
||||||
|
error: response.data.error
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refetch) refetch();
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("timetickets.errors.payall", {
|
title: t("timetickets.errors.payall", {
|
||||||
error: response.data.error
|
error: JSON.stringify("")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
if (refetch) refetch();
|
|
||||||
} else {
|
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("timetickets.errors.payall", {
|
title: t("timetickets.errors.payall", {
|
||||||
error: JSON.stringify("")
|
error: getRequestErrorMessage(error)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -241,10 +258,7 @@ export function PayrollLaborAllocationsTable({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
await loadTotals();
|
||||||
jobid: jobId
|
|
||||||
});
|
|
||||||
setTotals(data);
|
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
icon={<SyncOutlined />}
|
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."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Button } from "antd";
|
||||||
|
import ConfigListEmptyState from "./config-list-empty-state.component.jsx";
|
||||||
|
|
||||||
|
export const buildConfigListActionButton = ({ key, label, onClick, id }) => (
|
||||||
|
<Button key={key} type="primary" block id={id} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const renderConfigListOrEmpty = ({ fields, actionLabel, renderItems }) =>
|
||||||
|
fields.length === 0 ? <ConfigListEmptyState actionLabel={actionLabel} /> : renderItems();
|
||||||
|
|
||||||
|
export const buildSectionActionButton = (key, label, onClick, id) =>
|
||||||
|
buildConfigListActionButton({ key, label, onClick, id });
|
||||||
|
|
||||||
|
export const renderListOrEmpty = (fields, actionLabel, renderItems) =>
|
||||||
|
renderConfigListOrEmpty({ fields, actionLabel, renderItems });
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function ConfigListEmptyState({ actionLabel, minHeight = 96 }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="imex-form-row-empty-state" style={{ minHeight }}>
|
||||||
|
{t("general.labels.click_to_begin", { action: actionLabel })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { UnorderedListOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
export const inlineFormRowTitleStyles = Object.freeze({
|
||||||
|
input: Object.freeze({
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: "none",
|
||||||
|
paddingInline: 0,
|
||||||
|
paddingBlock: 0,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minWidth: 0,
|
||||||
|
width: "100%"
|
||||||
|
}),
|
||||||
|
row: Object.freeze({
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
paddingInline: 4
|
||||||
|
}),
|
||||||
|
group: Object.freeze({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingInline: 8,
|
||||||
|
paddingBlock: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid var(--imex-form-title-group-border)",
|
||||||
|
background: "var(--imex-form-title-group-bg)",
|
||||||
|
minWidth: 0,
|
||||||
|
flex: "1 1 0"
|
||||||
|
}),
|
||||||
|
label: Object.freeze({
|
||||||
|
color: "var(--ant-color-text-secondary)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
paddingInline: 6,
|
||||||
|
paddingBlock: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid var(--imex-form-title-label-border)",
|
||||||
|
background: "var(--imex-form-title-label-bg)"
|
||||||
|
}),
|
||||||
|
handle: Object.freeze({
|
||||||
|
color: "var(--ant-color-text-tertiary)",
|
||||||
|
fontSize: 14,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
marginRight: 2
|
||||||
|
}),
|
||||||
|
separator: Object.freeze({
|
||||||
|
width: 1,
|
||||||
|
height: 16,
|
||||||
|
background: "color-mix(in srgb, var(--imex-form-surface-border) 58%, transparent)",
|
||||||
|
borderRadius: 999,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
marginInline: 2
|
||||||
|
}),
|
||||||
|
text: Object.freeze({
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "var(--ant-font-size-lg)",
|
||||||
|
lineHeight: 1.2
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const INLINE_TITLE_INPUT_STYLE = inlineFormRowTitleStyles.input;
|
||||||
|
export const INLINE_TITLE_ROW_STYLE = inlineFormRowTitleStyles.row;
|
||||||
|
export const INLINE_TITLE_GROUP_STYLE = inlineFormRowTitleStyles.group;
|
||||||
|
export const InlineTitleListIcon = UnorderedListOutlined;
|
||||||
|
export const INLINE_TITLE_SWITCH_GROUP_STYLE = Object.freeze({
|
||||||
|
...inlineFormRowTitleStyles.group,
|
||||||
|
flex: "0 0 auto"
|
||||||
|
});
|
||||||
|
export const INLINE_TITLE_LABEL_STYLE = inlineFormRowTitleStyles.label;
|
||||||
|
export const INLINE_TITLE_HANDLE_STYLE = inlineFormRowTitleStyles.handle;
|
||||||
|
export const INLINE_TITLE_SEPARATOR_STYLE = inlineFormRowTitleStyles.separator;
|
||||||
|
export const INLINE_TITLE_TEXT_STYLE = inlineFormRowTitleStyles.text;
|
||||||
|
|
||||||
|
export const INLINE_FORM_ROW_WRAP_TITLE_STYLES = Object.freeze({
|
||||||
|
title: Object.freeze({
|
||||||
|
whiteSpace: "normal",
|
||||||
|
overflow: "visible",
|
||||||
|
textOverflow: "unset"
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Form } from "antd";
|
||||||
|
import LayoutFormRow from "./layout-form-row.component";
|
||||||
|
|
||||||
|
export default function InlineValidatedFormRow({ actions, errorNames = [], extraErrors = [], form, ...layoutFormRowProps }) {
|
||||||
|
const normalizedErrorNames = Array.isArray(errorNames) ? errorNames : [errorNames];
|
||||||
|
const normalizedExtraErrors = Array.isArray(extraErrors) ? extraErrors.filter(Boolean) : [extraErrors].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{() => {
|
||||||
|
const fieldErrors = normalizedErrorNames.flatMap((name) => form?.getFieldError?.(name) || []);
|
||||||
|
const errors = [...new Set([...fieldErrors, ...normalizedExtraErrors])];
|
||||||
|
const resolvedClassName = [
|
||||||
|
layoutFormRowProps.className,
|
||||||
|
errors.length > 0 ? "imex-form-row--error" : null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const normalizedActions = Array.isArray(actions) ? actions.filter(Boolean) : [actions].filter(Boolean);
|
||||||
|
const resolvedActions =
|
||||||
|
errors.length > 0
|
||||||
|
? [
|
||||||
|
<div
|
||||||
|
key="inline-form-row-footer"
|
||||||
|
className="imex-inline-form-row-errors"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: normalizedActions.length > 0 ? 8 : 0,
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
{normalizedActions.length > 0 ? <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>{normalizedActions}</div> : null}
|
||||||
|
</div>
|
||||||
|
]
|
||||||
|
: normalizedActions.length > 0
|
||||||
|
? normalizedActions
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return <LayoutFormRow {...layoutFormRowProps} className={resolvedClassName} actions={resolvedActions} />;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Card, Col, Row } from "antd";
|
import { Card, Col, Row } from "antd";
|
||||||
import { Children, isValidElement } from "react";
|
import { Children, isValidElement } from "react";
|
||||||
|
import { INLINE_FORM_ROW_WRAP_TITLE_STYLES } from "./inline-form-row-title.utils.js";
|
||||||
import "./layout-form-row.styles.scss";
|
import "./layout-form-row.styles.scss";
|
||||||
|
|
||||||
export default function LayoutFormRow({
|
export default function LayoutFormRow({
|
||||||
@@ -7,32 +8,45 @@ export default function LayoutFormRow({
|
|||||||
children,
|
children,
|
||||||
grow = false,
|
grow = false,
|
||||||
noDivider = false,
|
noDivider = false,
|
||||||
gutter = [16, 16], // Responsive gutter: horizontal, vertical
|
titleOnly = false,
|
||||||
|
wrapTitle = false,
|
||||||
|
gutter,
|
||||||
rowProps,
|
rowProps,
|
||||||
|
|
||||||
// Optional overrides if you ever need per-section customization
|
// Optional overrides if you ever need per-section customization
|
||||||
surface = true,
|
surface = true,
|
||||||
surfaceBg,
|
surfaceBg,
|
||||||
surfaceHeaderBg,
|
surfaceHeaderBg,
|
||||||
|
surfaceBorderColor,
|
||||||
|
|
||||||
...cardProps
|
...cardProps
|
||||||
}) {
|
}) {
|
||||||
const items = Children.toArray(children).filter(Boolean);
|
const items = Children.toArray(children).filter(Boolean);
|
||||||
if (items.length === 0) return null;
|
const isCompactRow = noDivider;
|
||||||
|
|
||||||
const title = !noDivider && header ? header : undefined;
|
const title = !noDivider && header ? header : undefined;
|
||||||
|
const resolvedTitle = cardProps.title ?? title;
|
||||||
|
const isHeaderOnly = titleOnly || items.length === 0;
|
||||||
|
const hideBody = isHeaderOnly;
|
||||||
|
|
||||||
|
if (items.length === 0 && !resolvedTitle) return null;
|
||||||
|
const resolvedGutter = gutter ?? [16, isCompactRow ? 8 : 16];
|
||||||
|
|
||||||
const bg = surfaceBg ?? (surface ? "var(--imex-form-surface)" : undefined);
|
const bg = surfaceBg ?? (surface ? "var(--imex-form-surface)" : undefined);
|
||||||
const headBg = surfaceHeaderBg ?? (surface ? "var(--imex-form-surface-head)" : undefined);
|
const headBg = surfaceHeaderBg ?? (surface ? "var(--imex-form-surface-head)" : undefined);
|
||||||
|
const borderColor = surfaceBorderColor ?? (surface ? "var(--imex-form-surface-border)" : undefined);
|
||||||
|
|
||||||
const mergedStyles = mergeSemanticStyles(
|
const mergedStyles = mergeSemanticStyles(
|
||||||
{
|
{
|
||||||
|
...(wrapTitle ? INLINE_FORM_ROW_WRAP_TITLE_STYLES : null),
|
||||||
header: {
|
header: {
|
||||||
paddingInline: 16,
|
paddingInline: isHeaderOnly ? 8 : isCompactRow ? 12 : 16,
|
||||||
background: headBg
|
background: headBg,
|
||||||
|
borderBottomColor: borderColor
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
padding: 16,
|
padding: hideBody ? 0 : isCompactRow ? 12 : 16,
|
||||||
|
display: hideBody ? "none" : undefined,
|
||||||
background: bg
|
background: bg
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -40,28 +54,12 @@ export default function LayoutFormRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const baseCardStyle = {
|
const baseCardStyle = {
|
||||||
marginBottom: ".8rem",
|
marginBottom: isHeaderOnly ? "0" : isCompactRow ? "8px" : ".8rem",
|
||||||
...(bg ? { background: bg } : null), // ensures the “circled area” is tinted
|
...(bg ? { background: bg } : null), // ensures the “circled area” is tinted
|
||||||
|
...(borderColor ? { borderColor } : null),
|
||||||
...cardProps.style
|
...cardProps.style
|
||||||
};
|
};
|
||||||
|
|
||||||
// single child => just render it
|
|
||||||
if (items.length === 1) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...cardProps}
|
|
||||||
title={cardProps.title ?? title}
|
|
||||||
size={cardProps.size ?? "small"}
|
|
||||||
variant={cardProps.variant ?? "outlined"}
|
|
||||||
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
|
|
||||||
style={baseCardStyle}
|
|
||||||
styles={mergedStyles}
|
|
||||||
>
|
|
||||||
{items[0]}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = items.length;
|
const count = items.length;
|
||||||
|
|
||||||
// Modern responsive strategy leveraging Ant Design 6:
|
// Modern responsive strategy leveraging Ant Design 6:
|
||||||
@@ -125,20 +123,32 @@ export default function LayoutFormRow({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
{...cardProps}
|
{...cardProps}
|
||||||
title={cardProps.title ?? title}
|
title={resolvedTitle}
|
||||||
size={cardProps.size ?? "small"}
|
size={cardProps.size ?? "small"}
|
||||||
variant={cardProps.variant ?? "outlined"}
|
variant={cardProps.variant ?? "outlined"}
|
||||||
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
|
className={[
|
||||||
|
"imex-form-row",
|
||||||
|
isCompactRow ? "imex-form-row--compact" : null,
|
||||||
|
isHeaderOnly ? "imex-form-row--title-only" : null,
|
||||||
|
cardProps.className
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
style={baseCardStyle}
|
style={baseCardStyle}
|
||||||
styles={mergedStyles}
|
styles={mergedStyles}
|
||||||
>
|
>
|
||||||
<Row gutter={gutter} wrap {...rowProps}>
|
{!isHeaderOnly &&
|
||||||
{items.map((child, idx) => (
|
(items.length === 1 ? (
|
||||||
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
|
items[0]
|
||||||
{child}
|
) : (
|
||||||
</Col>
|
<Row gutter={resolvedGutter} wrap {...rowProps}>
|
||||||
|
{items.map((child, idx) => (
|
||||||
|
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
|
||||||
|
{child}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Row>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,6 +162,7 @@ function mergeSemanticStyles(defaults, userStyles) {
|
|||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...computed,
|
...computed,
|
||||||
|
title: { ...(defaults.title || {}), ...(computed.title || {}) },
|
||||||
header: { ...defaults.header, ...(computed.header || {}) },
|
header: { ...defaults.header, ...(computed.header || {}) },
|
||||||
body: { ...defaults.body, ...(computed.body || {}) }
|
body: { ...defaults.body, ...(computed.body || {}) }
|
||||||
};
|
};
|
||||||
@@ -161,6 +172,7 @@ function mergeSemanticStyles(defaults, userStyles) {
|
|||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...userStyles,
|
...userStyles,
|
||||||
|
title: { ...(defaults.title || {}), ...(userStyles.title || {}) },
|
||||||
header: { ...defaults.header, ...(userStyles.header || {}) },
|
header: { ...defaults.header, ...(userStyles.header || {}) },
|
||||||
body: { ...defaults.body, ...(userStyles.body || {}) }
|
body: { ...defaults.body, ...(userStyles.body || {}) }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
--imex-form-surface: #fafafa; /* subtle contrast vs white page */
|
--imex-form-surface: #fafafa; /* subtle contrast vs white page */
|
||||||
--imex-form-surface-head: #f5f5f5; /* header strip */
|
--imex-form-surface-head: #f5f5f5; /* header strip */
|
||||||
--imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */
|
--imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */
|
||||||
|
--imex-form-title-input-bg: rgba(255, 255, 255, 0.96);
|
||||||
|
--imex-form-title-input-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--imex-form-title-group-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--imex-form-title-group-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--imex-form-title-label-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--imex-form-title-label-border: rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pick the selector that matches your app and remove the rest */
|
/* Pick the selector that matches your app and remove the rest */
|
||||||
@@ -20,6 +26,12 @@ html[data-theme="dark"] {
|
|||||||
--imex-form-surface: rgba(255, 255, 255, 0.01); /* subtle lift off page bg */
|
--imex-form-surface: rgba(255, 255, 255, 0.01); /* subtle lift off page bg */
|
||||||
--imex-form-surface-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */
|
--imex-form-surface-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */
|
||||||
--imex-form-surface-border: rgba(5, 5, 5, 0.12);
|
--imex-form-surface-border: rgba(5, 5, 5, 0.12);
|
||||||
|
--imex-form-title-input-bg: rgba(255, 255, 255, 0.12);
|
||||||
|
--imex-form-title-input-border: rgba(255, 255, 255, 0.2);
|
||||||
|
--imex-form-title-group-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--imex-form-title-group-border: rgba(255, 255, 255, 0.16);
|
||||||
|
--imex-form-title-label-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--imex-form-title-label-border: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.imex-form-row {
|
.imex-form-row {
|
||||||
@@ -38,18 +50,111 @@ html[data-theme="dark"] {
|
|||||||
border-color: var(--imex-form-surface-border);
|
border-color: var(--imex-form-surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.imex-form-row--error.ant-card {
|
||||||
|
border-color: var(--ant-color-error);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ant-color-error) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.ant-card-head {
|
.ant-card-head {
|
||||||
background: var(--imex-form-surface-head);
|
background: var(--imex-form-surface-head);
|
||||||
border-bottom-color: var(--imex-form-surface-border);
|
border-bottom-color: var(--imex-form-surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.imex-form-row--error {
|
||||||
|
.ant-card-head,
|
||||||
|
.ant-card-actions {
|
||||||
|
border-color: color-mix(in srgb, var(--ant-color-error) 34%, var(--imex-form-surface-border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.imex-form-row--compact {
|
||||||
|
.ant-card-head {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-head-title,
|
||||||
|
.ant-card-extra {
|
||||||
|
padding-block: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.imex-form-row--title-only {
|
||||||
|
.ant-card-head {
|
||||||
|
min-height: auto;
|
||||||
|
padding-inline: 6px;
|
||||||
|
padding-block: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-head-wrapper {
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-head-title,
|
||||||
|
.ant-card-extra {
|
||||||
|
padding-block: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: unset;
|
||||||
|
font-size: var(--ant-font-size);
|
||||||
|
line-height: 1.1;
|
||||||
|
padding-inline: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
display: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input,
|
||||||
|
.ant-input-number,
|
||||||
|
.ant-input-affix-wrapper,
|
||||||
|
.ant-select-selector,
|
||||||
|
.ant-picker {
|
||||||
|
background: var(--imex-form-title-input-bg);
|
||||||
|
border-color: var(--imex-form-title-input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-number-input {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ant-card-body {
|
.ant-card-body {
|
||||||
background: var(--imex-form-surface);
|
background: var(--imex-form-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-card-actions {
|
||||||
|
background: var(--imex-form-surface-head);
|
||||||
|
border-top-color: var(--imex-form-surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-actions > li {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-actions .ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item:last-child {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Optional: tighter spacing on phones for better space usage */
|
/* Optional: tighter spacing on phones for better space usage */
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
.ant-card-head {
|
&:not(.imex-form-row--title-only) .ant-card-head {
|
||||||
padding-inline: 12px;
|
padding-inline: 12px;
|
||||||
padding-block: 12px;
|
padding-block: 12px;
|
||||||
}
|
}
|
||||||
@@ -70,6 +175,14 @@ html[data-theme="dark"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-form-item:has(.imex-form-row--compact) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item:has(.imex-form-row--title-only) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Better form item spacing on mobile */
|
/* Better form item spacing on mobile */
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
.ant-form-item {
|
.ant-form-item {
|
||||||
@@ -77,3 +190,24 @@ html[data-theme="dark"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.imex-form-row-empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ant-color-text-description);
|
||||||
|
font-size: var(--ant-font-size);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imex-inline-form-row-errors {
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
|
||||||
|
.ant-form-item-explain,
|
||||||
|
.ant-form-item-explain-error,
|
||||||
|
.ant-form-item-additional {
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export function PartsOrderListTableComponent({
|
|||||||
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
|
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
|
||||||
|
|
||||||
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
|
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 { refetch } = billsQuery;
|
||||||
|
|
||||||
const recordActions = (record, showView = false) => (
|
const recordActions = (record, showView = false) => (
|
||||||
@@ -222,7 +228,12 @@ export function PartsOrderListTableComponent({
|
|||||||
dataIndex: "order_number",
|
dataIndex: "order_number",
|
||||||
key: "order_number",
|
key: "order_number",
|
||||||
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_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"),
|
title: t("parts_orders.fields.order_date"),
|
||||||
@@ -272,10 +283,10 @@ export function PartsOrderListTableComponent({
|
|||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPartsOrders = parts_orders
|
const filteredPartsOrders = enrichedPartsOrders
|
||||||
? searchText === ""
|
? searchText === ""
|
||||||
? parts_orders
|
? enrichedPartsOrders
|
||||||
: parts_orders.filter(
|
: enrichedPartsOrders.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
|
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())
|
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||||
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
|
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
|
||||||
@@ -50,6 +51,7 @@ export function PartsOrderModalComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const partsOrderLines = Form.useWatch(["parts_order_lines", "data"], form) || [];
|
||||||
const handleClick = ({ item }) => {
|
const handleClick = ({ item }) => {
|
||||||
form.setFieldsValue({ comments: item.props.value });
|
form.setFieldsValue({ comments: item.props.value });
|
||||||
};
|
};
|
||||||
@@ -128,10 +130,38 @@ export function PartsOrderModalComponent({
|
|||||||
{(fields, { remove, move }) => {
|
{(fields, { remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item required={false} key={field.key}>
|
const partsOrderLine = partsOrderLines[field.name] || {};
|
||||||
<div style={{ display: "flex" }}>
|
|
||||||
<LayoutFormRow grow noDivider style={{ flex: 1 }}>
|
return (
|
||||||
|
<Form.Item required={false} key={field.key}>
|
||||||
|
<LayoutFormRow
|
||||||
|
grow
|
||||||
|
noDivider
|
||||||
|
title={getFormListItemTitle(
|
||||||
|
t("parts_orders.fields.line_desc"),
|
||||||
|
index,
|
||||||
|
partsOrderLine.line_desc,
|
||||||
|
partsOrderLine.oem_partno
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
//span={8}
|
//span={8}
|
||||||
label={t("parts_orders.fields.line_desc")}
|
label={t("parts_orders.fields.line_desc")}
|
||||||
@@ -220,20 +250,9 @@ export function PartsOrderModalComponent({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<Space wrap size="small" align="center">
|
</Form.Item>
|
||||||
<div>
|
);
|
||||||
<DeleteFilled
|
})}
|
||||||
style={{ margin: "1rem" }}
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { Form, Input, InputNumber, Select, Typography } from "antd";
|
import { Button, Form, Input, InputNumber, Select, Space, Typography } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
@@ -15,6 +16,7 @@ export default connect(mapStateToProps, null)(PartsReceiveModalComponent);
|
|||||||
|
|
||||||
export function PartsReceiveModalComponent({ bodyshop, form }) {
|
export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const partsOrderLines = Form.useWatch(["partsorderlines"], form) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -42,16 +44,43 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
|||||||
{(fields, { remove, move }) => {
|
{(fields, { remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item required={false} key={field.key}>
|
const partsOrderLine = partsOrderLines[field.name] || {};
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
|
return (
|
||||||
|
<Form.Item required={false} key={field.key}>
|
||||||
<Form.Item hidden key={`${index}joblineid`} name={[field.name, "joblineid"]}>
|
<Form.Item hidden key={`${index}joblineid`} name={[field.name, "joblineid"]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
|
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<LayoutFormRow grow style={{ flex: 1 }}>
|
<LayoutFormRow
|
||||||
|
grow
|
||||||
|
title={getFormListItemTitle(
|
||||||
|
t("parts_orders.fields.line_desc"),
|
||||||
|
index,
|
||||||
|
partsOrderLine.line_desc,
|
||||||
|
partsOrderLine.oem_partno
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("parts_orders.fields.line_desc")}
|
label={t("parts_orders.fields.line_desc")}
|
||||||
key={`${index}line_desc`}
|
key={`${index}line_desc`}
|
||||||
@@ -101,16 +130,9 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
|||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<DeleteFilled
|
</Form.Item>
|
||||||
style={{ margin: "1rem" }}
|
);
|
||||||
onClick={() => {
|
})}
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
|||||||
import { Button, Form, Input, Select, Space } from "antd";
|
import { Button, Form, Input, Select, Space } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
export default function PartsEmailPresetsComponent() {
|
export default function PartsEmailPresetsComponent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
const emailPresets = Form.useWatch(["md_to_emails"], form) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -14,31 +17,46 @@ export default function PartsEmailPresetsComponent() {
|
|||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item key={field.key}>
|
const preset = emailPresets[field.name] || {};
|
||||||
<LayoutFormRow noDivider>
|
|
||||||
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.labels.md_to_emails_emails")}
|
|
||||||
key={`${index}emails`}
|
|
||||||
name={[field.name, "emails"]}
|
|
||||||
>
|
|
||||||
<Select mode="tags" tokenSeparators={[",", ";"]} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Space>
|
return (
|
||||||
<DeleteFilled
|
<Form.Item key={field.key}>
|
||||||
onClick={() => {
|
<LayoutFormRow
|
||||||
remove(field.name);
|
noDivider
|
||||||
}}
|
title={getFormListItemTitle(t("general.labels.label"), index, preset.label, preset.emails)}
|
||||||
/>
|
extra={
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
<Space align="center" size="small">
|
||||||
</Space>
|
<Button
|
||||||
</LayoutFormRow>
|
type="text"
|
||||||
</Form.Item>
|
icon={<DeleteFilled />}
|
||||||
))}
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.labels.md_to_emails_emails")}
|
||||||
|
key={`${index}emails`}
|
||||||
|
name={[field.name, "emails"]}
|
||||||
|
>
|
||||||
|
<Select mode="tags" tokenSeparators={[",", ";"]} />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
|||||||
import { Button, Form, Input, Space } from "antd";
|
import { Button, Form, Input, Space } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
export default function PartsLocationsComponent() {
|
export default function PartsLocationsComponent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
const partsLocations = Form.useWatch(["md_parts_locations"], form) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -14,34 +17,49 @@ export default function PartsLocationsComponent() {
|
|||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item key={field.key}>
|
const location = partsLocations[field.name];
|
||||||
<LayoutFormRow noDivider>
|
|
||||||
<Form.Item
|
return (
|
||||||
className="imex-flex-row__margin"
|
<Form.Item key={field.key}>
|
||||||
label={t("bodyshop.fields.partslocation")}
|
<LayoutFormRow
|
||||||
key={`${index}`}
|
noDivider
|
||||||
name={[field.name]}
|
title={getFormListItemTitle(t("bodyshop.fields.partslocation"), index, location)}
|
||||||
rules={[
|
extra={
|
||||||
{
|
<Space align="center" size="small">
|
||||||
required: true
|
<Button
|
||||||
}
|
type="text"
|
||||||
]}
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input />
|
<Form.Item
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
className="imex-flex-row__margin"
|
className="imex-flex-row__margin"
|
||||||
onClick={() => {
|
label={t("bodyshop.fields.partslocation")}
|
||||||
remove(field.name);
|
key={`${index}`}
|
||||||
}}
|
name={[field.name]}
|
||||||
/>
|
rules={[
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
{
|
||||||
</Space>
|
required: true
|
||||||
</LayoutFormRow>
|
}
|
||||||
</Form.Item>
|
]}
|
||||||
))}
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
|||||||
import { Button, Form, Input, Space } from "antd";
|
import { Button, Form, Input, Space } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
export default function PartsOrderCommentsComponent() {
|
export default function PartsOrderCommentsComponent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
const orderComments = Form.useWatch(["md_parts_order_comment"], form) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -14,45 +17,65 @@ export default function PartsOrderCommentsComponent() {
|
|||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item key={field.key}>
|
const comment = orderComments[field.name] || {};
|
||||||
<LayoutFormRow noDivider>
|
|
||||||
<Form.Item
|
|
||||||
label={t("general.labels.label")}
|
|
||||||
key={`${index}label`}
|
|
||||||
name={[field.name, "label"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("parts_orders.fields.comments")}
|
|
||||||
key={`${index}comment`}
|
|
||||||
name={[field.name, "comment"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.TextArea autoSize />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Space wrap>
|
return (
|
||||||
<DeleteFilled
|
<Form.Item key={field.key}>
|
||||||
onClick={() => {
|
<LayoutFormRow
|
||||||
remove(field.name);
|
noDivider
|
||||||
}}
|
title={getFormListItemTitle(
|
||||||
/>
|
t("parts_orders.fields.comments"),
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
index,
|
||||||
</Space>
|
comment.label,
|
||||||
</LayoutFormRow>
|
comment.comment
|
||||||
</Form.Item>
|
)}
|
||||||
))}
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={t("general.labels.label")}
|
||||||
|
key={`${index}label`}
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("parts_orders.fields.comments")}
|
||||||
|
key={`${index}comment`}
|
||||||
|
name={[field.name, "comment"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.TextArea autoSize />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function ProductionColumnsComponent({
|
|||||||
|
|
||||||
const columnKeys = columns.map((i) => i.key);
|
const columnKeys = columns.map((i) => i.key);
|
||||||
const cols = dataSource({
|
const cols = dataSource({
|
||||||
|
bodyshop,
|
||||||
technician,
|
technician,
|
||||||
data,
|
data,
|
||||||
state: tableState,
|
state: tableState,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Icon from "@ant-design/icons";
|
import Icon from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client/react";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import { Button, Input, Popover, Tooltip } from "antd";
|
import { Button, Input, Popover, Tooltip } from "antd";
|
||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaRegStickyNote } from "react-icons/fa";
|
import { FaRegStickyNote } from "react-icons/fa";
|
||||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
@@ -9,10 +9,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
|||||||
|
|
||||||
export default function ProductionListColumnComment({ record, usePortal = false }) {
|
export default function ProductionListColumnComment({ record, usePortal = false }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [note, setNote] = useState(record.comment || "");
|
const [note, setNote] = useState(record.comment || "");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const textAreaRef = useRef(null);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
|
||||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||||
|
|
||||||
@@ -38,23 +38,35 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (flag) => {
|
const handleOpenChange = (flag) => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
setOpen(flag);
|
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 = (
|
const content = (
|
||||||
<div
|
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
style={{ width: "30em" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
id={`job-comment-${record.id}`}
|
id={`job-comment-${record.id}`}
|
||||||
name="comment"
|
name="comment"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
autoFocus
|
ref={textAreaRef}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: "1em" }}
|
style={{ marginBottom: "1em" }}
|
||||||
/>
|
/>
|
||||||
@@ -73,7 +85,7 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
|||||||
content={content}
|
content={content}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
styles={{ body: { padding: '12px' } }}
|
styles={{ body: { padding: "12px" } }}
|
||||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
|
|||||||
import { store } from "../../redux/store";
|
import { store } from "../../redux/store";
|
||||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||||
import { TimeFormatter } from "../../utils/DateFormatter";
|
import { TimeFormatter } from "../../utils/DateFormatter";
|
||||||
import PhoneFormatter from "../../utils/PhoneFormatter";
|
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||||
import { onlyUnique } from "../../utils/arrayHelper";
|
import { onlyUnique } from "../../utils/arrayHelper";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
|
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 ProductionListColumnStatus from "./production-list-columns.status.component";
|
||||||
import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component";
|
import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component";
|
||||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
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 getEmployeeName = (employeeId, employees) => {
|
||||||
const employee = employees.find((e) => e.id === employeeId);
|
const employee = employees.find((e) => e.id === employeeId);
|
||||||
@@ -271,14 +272,24 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
|||||||
dataIndex: "ownr_ph1",
|
dataIndex: "ownr_ph1",
|
||||||
key: "ownr_ph1",
|
key: "ownr_ph1",
|
||||||
ellipsis: true,
|
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"),
|
title: i18n.t("jobs.fields.ownr_ph2"),
|
||||||
dataIndex: "ownr_ph2",
|
dataIndex: "ownr_ph2",
|
||||||
key: "ownr_ph2",
|
key: "ownr_ph2",
|
||||||
ellipsis: true,
|
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"),
|
title: i18n.t("jobs.fields.specialcoveragepolicy"),
|
||||||
@@ -598,7 +609,19 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
|
||||||
render: (text, record) => <TimeFormatter>{record.date_repairstarted}</TimeFormatter>
|
render: (text, record) => <TimeFormatter>{record.date_repairstarted}</TimeFormatter>
|
||||||
}
|
},
|
||||||
|
...(bodyshop && bodyshop.rr_dealerid
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: i18n.t("jobs.fields.dms.id"),
|
||||||
|
dataIndex: "dms_id",
|
||||||
|
key: "dms_id",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: (a, b) => alphaSort(a.dms_id, b.dms_id),
|
||||||
|
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
export default productionListColumnsData;
|
export default productionListColumnsData;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Icon from "@ant-design/icons";
|
import Icon from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client/react";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import { Button, Input, Popover, Space } from "antd";
|
import { Button, Input, Popover, Space } from "antd";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaRegStickyNote } from "react-icons/fa";
|
import { FaRegStickyNote } from "react-icons/fa";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
@@ -20,6 +20,8 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [note, setNote] = useState(record.production_vars?.note || "");
|
const [note, setNote] = useState(record.production_vars?.note || "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const textAreaRef = useRef(null);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
|
||||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||||
|
|
||||||
@@ -52,25 +54,37 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(flag) => {
|
(flag) => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
setOpen(flag);
|
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]
|
[record]
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
style={{ width: "30em" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
id={`job-production-note-${record.id}`}
|
id={`job-production-note-${record.id}`}
|
||||||
name="production_note"
|
name="production_note"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
autoFocus
|
ref={textAreaRef}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: "1em" }}
|
style={{ marginBottom: "1em" }}
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +116,7 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
content={content}
|
content={content}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
styles={{ body: { padding: '12px' } }}
|
styles={{ body: { padding: "12px" } }}
|
||||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export function ProductionListConfigManager({
|
|||||||
nextConfig.columns.columnKeys.map((k) => {
|
nextConfig.columns.columnKeys.map((k) => {
|
||||||
return {
|
return {
|
||||||
...ProductionListColumns({
|
...ProductionListColumns({
|
||||||
|
bodyshop,
|
||||||
technician,
|
technician,
|
||||||
state: ensureDefaultState(state),
|
state: ensureDefaultState(state),
|
||||||
refetch,
|
refetch,
|
||||||
@@ -270,6 +271,7 @@ export function ProductionListConfigManager({
|
|||||||
activeConfig.columns.columnKeys.map((k) => {
|
activeConfig.columns.columnKeys.map((k) => {
|
||||||
return {
|
return {
|
||||||
...ProductionListColumns({
|
...ProductionListColumns({
|
||||||
|
bodyshop,
|
||||||
technician,
|
technician,
|
||||||
state: ensureDefaultState(state),
|
state: ensureDefaultState(state),
|
||||||
refetch,
|
refetch,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ret = {
|
|||||||
"jobs:partsqueue": 4,
|
"jobs:partsqueue": 4,
|
||||||
"jobs:checklist-view": 2,
|
"jobs:checklist-view": 2,
|
||||||
"jobs:list-ready": 1,
|
"jobs:list-ready": 1,
|
||||||
|
"jobs:manual-line": 1,
|
||||||
"jobs:void": 5,
|
"jobs:void": 5,
|
||||||
|
|
||||||
"bills:enter": 2,
|
"bills:enter": 2,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { INSERT_VACATION } from "../../graphql/employees.queries";
|
|||||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
export default function ShopEmployeeAddVacation({ employee }) {
|
export default function ShopEmployeeAddVacation({ employee, buttonProps }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [insertVacation] = useMutation(INSERT_VACATION);
|
const [insertVacation] = useMutation(INSERT_VACATION);
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export default function ShopEmployeeAddVacation({ employee }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover content={overlay} open={visibility}>
|
<Popover content={overlay} open={visibility}>
|
||||||
<Button loading={loading} disabled={!employee?.active} onClick={handleClick}>
|
<Button loading={loading} disabled={!employee?.active} onClick={handleClick} {...buttonProps}>
|
||||||
{t("employees.actions.addvacation")}
|
{t("employees.actions.addvacation")}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Card, Form, Input, InputNumber, Select, Switch } from "antd";
|
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import { useForm } from "antd/es/form/Form";
|
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
@@ -26,9 +25,24 @@ import { DateFormatter } from "../../utils/DateFormatter";
|
|||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||||
|
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
INLINE_TITLE_SEPARATOR_STYLE,
|
||||||
|
INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_TEXT_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
|
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
|
||||||
|
import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -37,19 +51,38 @@ const mapDispatchToProps = () => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ShopEmployeesFormComponent({ bodyshop }) {
|
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
|
||||||
|
const submitActionRef = useRef("save");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = useForm();
|
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
||||||
|
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
||||||
|
const employeeNumber = Form.useWatch("employee_number", form);
|
||||||
|
const firstName = Form.useWatch("first_name", form);
|
||||||
|
const lastName = Form.useWatch("last_name", form);
|
||||||
|
const employeeOptionsColProps = {
|
||||||
|
xs: 24,
|
||||||
|
sm: 12,
|
||||||
|
md: 12,
|
||||||
|
lg: 8,
|
||||||
|
xl: 8,
|
||||||
|
xxl: 8
|
||||||
|
};
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const [deleteVacation] = useMutation(DELETE_VACATION);
|
const [deleteVacation] = useMutation(DELETE_VACATION);
|
||||||
const { error, data } = useQuery(QUERY_EMPLOYEE_BY_ID, {
|
const { error, data, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, {
|
||||||
variables: { id: search.employeeId },
|
variables: { id: search.employeeId },
|
||||||
skip: !search.employeeId || search.employeeId === "new",
|
skip: !search.employeeId || search.employeeId === "new",
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
const isNewEmployee = search.employeeId === "new";
|
||||||
|
const currentEmployeeData = data?.employees_by_pk?.id === search.employeeId ? data.employees_by_pk : null;
|
||||||
|
const employeeTitleName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||||
|
const employeeCardTitle =
|
||||||
|
[employeeNumber, employeeTitleName].filter(Boolean).join(" - ") ||
|
||||||
|
(isNewEmployee ? t("employees.actions.new") : t("bodyshop.labels.employees"));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
treatments: { Enhanced_Payroll }
|
treatments: { Enhanced_Payroll }
|
||||||
@@ -59,56 +92,150 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
|||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop.imexshopid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateDirtyState = useCallback(
|
||||||
|
(nextDirtyState) => {
|
||||||
|
setInternalIsDirty(nextDirtyState);
|
||||||
|
onDirtyChange?.(nextDirtyState);
|
||||||
|
},
|
||||||
|
[onDirtyChange]
|
||||||
|
);
|
||||||
|
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
useEffect(() => {
|
const clearEmployeeFormMeta = useCallback(() => {
|
||||||
if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk);
|
const fieldMeta = form.getFieldsError().map(({ name }) => ({
|
||||||
else {
|
name,
|
||||||
form.resetFields();
|
touched: false,
|
||||||
|
validating: false,
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fieldMeta.length > 0) {
|
||||||
|
form.setFields(fieldMeta);
|
||||||
}
|
}
|
||||||
}, [form, data, search.employeeId]);
|
|
||||||
|
updateDirtyState(false);
|
||||||
|
}, [form, updateDirtyState]);
|
||||||
|
|
||||||
|
const resetEmployeeFormToCurrentData = useCallback(() => {
|
||||||
|
form.resetFields();
|
||||||
|
|
||||||
|
if (currentEmployeeData) {
|
||||||
|
form.setFieldsValue(currentEmployeeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
clearEmployeeFormMeta();
|
||||||
|
});
|
||||||
|
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
|
||||||
|
|
||||||
|
const syncEmployeeFormToSavedData = useCallback(
|
||||||
|
(employeeData) => {
|
||||||
|
if (employeeData) {
|
||||||
|
form.setFieldsValue(employeeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
clearEmployeeFormMeta();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearEmployeeFormMeta, form]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetEmployeeFormToCurrentData();
|
||||||
|
}, [resetEmployeeFormToCurrentData, search.employeeId]);
|
||||||
|
|
||||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
|
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
|
||||||
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
|
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
|
||||||
|
const saveAndResetSubmitAction = useCallback(() => {
|
||||||
|
const submitAction = submitActionRef.current;
|
||||||
|
submitActionRef.current = "save";
|
||||||
|
return submitAction;
|
||||||
|
}, []);
|
||||||
|
const submitEmployeeForm = useCallback(
|
||||||
|
(submitAction = "save") => {
|
||||||
|
submitActionRef.current = submitAction;
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
const navigateToEmployee = useCallback(
|
||||||
|
(employeeId) => {
|
||||||
|
history({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...search,
|
||||||
|
employeeId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[history, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFinish = async (values) => {
|
||||||
|
const submitAction = saveAndResetSubmitAction();
|
||||||
|
const normalizedValues = {
|
||||||
|
...values,
|
||||||
|
user_email: values.user_email === "" ? null : values.user_email
|
||||||
|
};
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
|
||||||
if (search.employeeId && search.employeeId !== "new") {
|
if (search.employeeId && search.employeeId !== "new") {
|
||||||
//Update a record.
|
//Update a record.
|
||||||
logImEXEvent("shop_employee_update");
|
logImEXEvent("shop_employee_update");
|
||||||
|
|
||||||
updateEmployee({
|
try {
|
||||||
variables: {
|
const result = await updateEmployee({
|
||||||
id: search.employeeId,
|
variables: {
|
||||||
employee: {
|
id: search.employeeId,
|
||||||
...values,
|
employee: normalizedValues
|
||||||
user_email: values.user_email === "" ? null : values.user_email
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
notification.success({
|
|
||||||
title: t("employees.successes.save")
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
notification.error({
|
|
||||||
title: t("employees.errors.save", {
|
|
||||||
message: JSON.stringify(error)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
//New record, insert it.
|
|
||||||
logImEXEvent("shop_employee_insert");
|
|
||||||
|
|
||||||
insertEmployees({
|
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
|
||||||
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
|
void refetch();
|
||||||
refetchQueries: ["QUERY_EMPLOYEES"]
|
if (submitAction === "saveAndNew") {
|
||||||
}).then((r) => {
|
navigateToEmployee("new");
|
||||||
search.employeeId = r.data.insert_employees.returning[0].id;
|
}
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: t("employees.errors.save", {
|
||||||
|
message: JSON.stringify(error)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//New record, insert it.
|
||||||
|
logImEXEvent("shop_employee_insert");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await insertEmployees({
|
||||||
|
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
|
||||||
|
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||||
|
});
|
||||||
|
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
||||||
|
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
|
|
||||||
|
if (submitAction === "saveAndNew") {
|
||||||
|
navigateToEmployee("new");
|
||||||
|
} else if (savedEmployee?.id) {
|
||||||
|
navigateToEmployee(savedEmployee.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
title: t("employees.successes.save")
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: t("employees.errors.save", {
|
||||||
|
message: JSON.stringify(error)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -141,6 +268,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
render: (text, record) => (
|
render: (text, record) => (
|
||||||
<Button
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteVacation({
|
await deleteVacation({
|
||||||
variables: { id: record.id },
|
variables: { id: record.id },
|
||||||
@@ -168,226 +297,365 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
title={employeeCardTitle}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => form.submit()}>
|
<Space wrap>
|
||||||
{t("general.actions.save")}
|
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
||||||
</Button>
|
{t("general.actions.saveandnew") || "Save and New"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => submitEmployeeForm("save")}
|
||||||
|
disabled={!resolvedIsDirty}
|
||||||
|
style={{ minWidth: 170 }}
|
||||||
|
>
|
||||||
|
{t("employees.actions.save_employee")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
<Form
|
||||||
<LayoutFormRow>
|
onFinish={handleFinish}
|
||||||
<Form.Item
|
onFinishFailed={saveAndResetSubmitAction}
|
||||||
name="first_name"
|
autoComplete={"off"}
|
||||||
label={t("employees.fields.first_name")}
|
layout="vertical"
|
||||||
rules={[
|
form={form}
|
||||||
{
|
onValuesChange={() => {
|
||||||
required: true
|
updateDirtyState(form.isFieldsTouched());
|
||||||
//message: t("general.validation.required"),
|
}}
|
||||||
}
|
>
|
||||||
]}
|
<FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} />
|
||||||
>
|
<LayoutFormRow
|
||||||
<Input />
|
title={
|
||||||
</Form.Item>
|
<div
|
||||||
<Form.Item
|
style={{
|
||||||
label={t("employees.fields.last_name")}
|
...INLINE_TITLE_ROW_STYLE,
|
||||||
name="last_name"
|
justifyContent: "space-between"
|
||||||
rules={[
|
}}
|
||||||
{
|
>
|
||||||
required: true
|
<div
|
||||||
//message: t("general.validation.required"),
|
style={{
|
||||||
}
|
...INLINE_TITLE_TEXT_STYLE,
|
||||||
]}
|
marginRight: "auto"
|
||||||
>
|
}}
|
||||||
<Input />
|
>
|
||||||
</Form.Item>
|
{t("bodyshop.labels.employee_options")}
|
||||||
<Form.Item
|
</div>
|
||||||
name="employee_number"
|
<div
|
||||||
label={t("employees.fields.employee_number")}
|
style={{
|
||||||
validateTrigger="onBlur"
|
display: "flex",
|
||||||
hasFeedback
|
alignItems: "center",
|
||||||
rules={[
|
gap: 4,
|
||||||
{
|
flexWrap: "wrap",
|
||||||
required: true
|
marginLeft: "auto"
|
||||||
//message: t("general.validation.required"),
|
}}
|
||||||
},
|
>
|
||||||
() => ({
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
async validator(rule, value) {
|
<div
|
||||||
if (value) {
|
style={{
|
||||||
const response = await client.query({
|
...INLINE_TITLE_SWITCH_GROUP_STYLE
|
||||||
query: CHECK_EMPLOYEE_NUMBER,
|
}}
|
||||||
variables: {
|
>
|
||||||
employeenumber: value
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.labels.active")}</div>
|
||||||
}
|
<Form.Item noStyle valuePropName="checked" name="active">
|
||||||
});
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
if (response.data.employees_aggregate.aggregate.count === 0) {
|
</div>
|
||||||
return Promise.resolve();
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
} else if (
|
<div
|
||||||
response.data.employees_aggregate.nodes.length === 1 &&
|
style={{
|
||||||
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
|
...INLINE_TITLE_SWITCH_GROUP_STYLE
|
||||||
) {
|
}}
|
||||||
return Promise.resolve();
|
>
|
||||||
}
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.flat_rate")}</div>
|
||||||
return Promise.reject(t("employees.validation.unique_employee_number"));
|
<Form.Item noStyle valuePropName="checked" name="flat_rate">
|
||||||
} else {
|
<Switch />
|
||||||
return Promise.resolve();
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} wrap>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item
|
||||||
|
name="first_name"
|
||||||
|
label={t("employees.fields.first_name")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
}
|
}
|
||||||
}
|
]}
|
||||||
})
|
>
|
||||||
]}
|
<Input />
|
||||||
>
|
</Form.Item>
|
||||||
<Input />
|
</Col>
|
||||||
</Form.Item>
|
<Col {...employeeOptionsColProps}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("employees.fields.pin")}
|
label={t("employees.fields.last_name")}
|
||||||
name="pin"
|
name="last_name"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true
|
required: true
|
||||||
//message: t("general.validation.required"),
|
//message: t("general.validation.required"),
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
</LayoutFormRow>
|
|
||||||
<LayoutFormRow>
|
|
||||||
<Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="hire_date"
|
|
||||||
label={t("employees.fields.hire_date")}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<DateTimePicker isDateOnly />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
|
|
||||||
<DateTimePicker isDateOnly />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("employees.fields.user_email")}
|
|
||||||
name="user_email"
|
|
||||||
validateTrigger="onBlur"
|
|
||||||
rules={[
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
async validator(rule, value) {
|
|
||||||
const user_email = getFieldValue("user_email");
|
|
||||||
|
|
||||||
if (user_email && value) {
|
|
||||||
const response = await client.query({
|
|
||||||
query: QUERY_USERS_BY_EMAIL,
|
|
||||||
variables: {
|
|
||||||
email: user_email
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.users.length === 1) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
|
||||||
} else {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
}
|
]}
|
||||||
})
|
>
|
||||||
]}
|
<Input />
|
||||||
>
|
</Form.Item>
|
||||||
<Input />
|
</Col>
|
||||||
</Form.Item>
|
<Col {...employeeOptionsColProps}>
|
||||||
<Form.Item label={t("employees.fields.external_id")} name="external_id">
|
<Form.Item
|
||||||
<Input />
|
name="employee_number"
|
||||||
</Form.Item>
|
label={t("employees.fields.employee_number")}
|
||||||
|
validateTrigger="onBlur"
|
||||||
|
hasFeedback
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
},
|
||||||
|
() => ({
|
||||||
|
async validator(rule, value) {
|
||||||
|
if (value) {
|
||||||
|
const response = await client.query({
|
||||||
|
query: CHECK_EMPLOYEE_NUMBER,
|
||||||
|
variables: {
|
||||||
|
employeenumber: value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.employees_aggregate.aggregate.count === 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
} else if (
|
||||||
|
response.data.employees_aggregate.nodes.length === 1 &&
|
||||||
|
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
|
||||||
|
) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(t("employees.validation.unique_employee_number"));
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("employees.fields.pin")}
|
||||||
|
name="pin"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item
|
||||||
|
name="hire_date"
|
||||||
|
label={t("employees.fields.hire_date")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<DateTimePicker isDateOnly />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
|
||||||
|
<DateTimePicker isDateOnly />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("employees.fields.user_email")}
|
||||||
|
name="user_email"
|
||||||
|
validateTrigger="onBlur"
|
||||||
|
rules={[
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
async validator(rule, value) {
|
||||||
|
const user_email = getFieldValue("user_email");
|
||||||
|
|
||||||
|
if (user_email && value) {
|
||||||
|
const response = await client.query({
|
||||||
|
query: QUERY_USERS_BY_EMAIL,
|
||||||
|
variables: {
|
||||||
|
email: user_email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.users.length === 1) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FormItemEmail />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...employeeOptionsColProps}>
|
||||||
|
<Form.Item label={t("employees.fields.external_id")} name="external_id">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<Form.List name={["rates"]}>
|
<Form.List name={["rates"]}>
|
||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow
|
||||||
{fields.map((field, index) => (
|
title={t("bodyshop.labels.employee_rates")}
|
||||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
actions={[
|
||||||
<LayoutFormRow grow>
|
|
||||||
<Form.Item
|
|
||||||
label={t("employees.fields.cost_center")}
|
|
||||||
key={`${index}`}
|
|
||||||
name={[field.name, "cost_center"]}
|
|
||||||
valuePropName="value"
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
|
|
||||||
...(bodyshop.cdk_dealerid ||
|
|
||||||
bodyshop.pbs_serialnumber ||
|
|
||||||
bodyshop.rr_dealerid ||
|
|
||||||
Enhanced_Payroll.treatment === "on"
|
|
||||||
? CiecaSelect(false, true)
|
|
||||||
: bodyshop.md_responsibility_centers.costs.map((c) => ({
|
|
||||||
value: c.name,
|
|
||||||
label: c.name
|
|
||||||
})))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("employees.fields.rate")}
|
|
||||||
key={`${index}`}
|
|
||||||
name={[field.name, "rate"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} precision={2} />
|
|
||||||
</Form.Item>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
key="add-rate"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
add();
|
add();
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%" }}
|
|
||||||
id="add-employee-rate-button"
|
id="add-employee-rate-button"
|
||||||
>
|
>
|
||||||
<span id="new-employee-rate">{t("employees.actions.newrate")}</span>
|
<span id="new-employee-rate">{t("employees.actions.addrate")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
]}
|
||||||
</div>
|
>
|
||||||
|
<div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("employees.actions.addrate")} />
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle key={field.key}>
|
||||||
|
<InlineValidatedFormRow
|
||||||
|
form={form}
|
||||||
|
errorNames={[["rates", field.name, "cost_center"]]}
|
||||||
|
noDivider
|
||||||
|
title={
|
||||||
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.cost_center")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "cost_center"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
options={[
|
||||||
|
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
|
||||||
|
...(bodyshop.cdk_dealerid ||
|
||||||
|
bodyshop.pbs_serialnumber ||
|
||||||
|
bodyshop.rr_dealerid ||
|
||||||
|
Enhanced_Payroll.treatment === "on"
|
||||||
|
? CiecaSelect(false, true)
|
||||||
|
: bodyshop.md_responsibility_centers.costs.map((c) => ({
|
||||||
|
value: c.name,
|
||||||
|
label: c.name
|
||||||
|
})))
|
||||||
|
]}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
styles={{
|
||||||
|
selector: INLINE_TITLE_INPUT_STYLE
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={t("employees.fields.rate")}
|
||||||
|
name={[field.name, "rate"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={2} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<ResponsiveTable
|
<LayoutFormRow
|
||||||
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
|
title={t("bodyshop.labels.employee_vacation")}
|
||||||
columns={columns}
|
actions={[
|
||||||
mobileColumnKeys={["start", "length", "actions"]}
|
<ShopEmployeeAddVacation
|
||||||
rowKey={"id"}
|
key="add-vacation"
|
||||||
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
|
employee={data && data.employees_by_pk}
|
||||||
/>
|
buttonProps={{
|
||||||
|
type: "primary",
|
||||||
|
block: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(data?.employees_by_pk?.employee_vacations ?? []).length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("employees.actions.addvacation")} />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<ResponsiveTable
|
||||||
|
columns={columns}
|
||||||
|
mobileColumnKeys={["start", "length", "actions"]}
|
||||||
|
rowKey={"id"}
|
||||||
|
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LayoutFormRow>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { useApolloClient } from "@apollo/client/react";
|
||||||
|
import { Form } from "antd";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
|
||||||
|
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
||||||
|
|
||||||
|
const insertEmployeesMock = vi.fn();
|
||||||
|
const updateEmployeeMock = vi.fn();
|
||||||
|
const deleteVacationMock = 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", async () => {
|
||||||
|
const actual = await vi.importActual("@apollo/client/react");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useApolloClient: vi.fn(),
|
||||||
|
useQuery: (...args) => useQueryMock(...args),
|
||||||
|
useMutation: (...args) => useMutationMock(...args)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@splitsoftware/splitio-react", () => ({
|
||||||
|
useTreatmentsWithConfig: () => ({
|
||||||
|
treatments: {
|
||||||
|
Enhanced_Payroll: {
|
||||||
|
treatment: "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useLocation: () => ({
|
||||||
|
search: "?employeeId=new"
|
||||||
|
}),
|
||||||
|
useNavigate: () => navigateMock
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key, values = {}) => {
|
||||||
|
const translations = {
|
||||||
|
"bodyshop.labels.employee_options": "Employee Options",
|
||||||
|
"bodyshop.labels.employee_rates": "Employee Rates",
|
||||||
|
"bodyshop.labels.employee_vacation": "Employee Vacation",
|
||||||
|
"bodyshop.labels.employees": "Employees",
|
||||||
|
"employees.actions.addrate": "Add Rate",
|
||||||
|
"employees.actions.addvacation": "Add Vacation",
|
||||||
|
"employees.actions.new": "New Employee",
|
||||||
|
"employees.actions.save_employee": "Save Employee",
|
||||||
|
"employees.fields.active": "Active",
|
||||||
|
"employees.fields.employee_number": "Employee Number",
|
||||||
|
"employees.fields.external_id": "External Id",
|
||||||
|
"employees.fields.first_name": "First Name",
|
||||||
|
"employees.fields.flat_rate": "Flat Rate",
|
||||||
|
"employees.fields.hire_date": "Hire Date",
|
||||||
|
"employees.fields.last_name": "Last Name",
|
||||||
|
"employees.fields.pin": "PIN",
|
||||||
|
"employees.fields.rate": "Rate",
|
||||||
|
"employees.fields.termination_date": "Termination Date",
|
||||||
|
"employees.fields.user_email": "User Email",
|
||||||
|
"employees.labels.active": "Active",
|
||||||
|
"employees.successes.save": "Saved",
|
||||||
|
"general.actions.saveandnew": "Save and New",
|
||||||
|
"general.labels.actions": "Actions"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (key === "employees.errors.save") {
|
||||||
|
return `Save failed: ${values.message ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "employees.validation.unique_employee_number") {
|
||||||
|
return "Employee number must be unique";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "bodyshop.validation.useremailmustexist") {
|
||||||
|
return "User email must exist";
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations[key] || key;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||||
|
useNotification: () => notification
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../firebase/firebase.utils", () => ({
|
||||||
|
logImEXEvent: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../alert/alert.component", () => ({
|
||||||
|
default: ({ title }) => <div>{title}</div>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
|
||||||
|
default: ({ id, value, onChange }) => (
|
||||||
|
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
|
||||||
|
default: ({ id, value, onChange }) => (
|
||||||
|
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/layout-form-row.component", () => ({
|
||||||
|
default: ({ title, extra, actions, children }) => (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{extra}
|
||||||
|
{children}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
|
||||||
|
default: ({ title, extra, children }) => (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{extra}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
|
||||||
|
default: ({ actionLabel }) => <div>{actionLabel}</div>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../responsive-table/responsive-table.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./shop-employees-add-vacation.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/Ciecaselect", () => ({
|
||||||
|
default: () => []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bodyshop = {
|
||||||
|
id: "shop-1",
|
||||||
|
imexshopid: "split-shop-1",
|
||||||
|
md_responsibility_centers: {
|
||||||
|
costs: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ShopEmployeesFormComponent", () => {
|
||||||
|
let formInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useQueryMock.mockImplementation((query) => {
|
||||||
|
if (query === QUERY_EMPLOYEE_BY_ID) {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useMutationMock.mockImplementation((mutation) => {
|
||||||
|
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
|
||||||
|
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
|
||||||
|
if (mutation === DELETE_VACATION) return [deleteVacationMock];
|
||||||
|
return [vi.fn()];
|
||||||
|
});
|
||||||
|
|
||||||
|
useApolloClient.mockReturnValue({
|
||||||
|
query: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
employees_aggregate: {
|
||||||
|
aggregate: {
|
||||||
|
count: 0
|
||||||
|
},
|
||||||
|
nodes: []
|
||||||
|
},
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
insertEmployeesMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
insert_employees: {
|
||||||
|
returning: [
|
||||||
|
{
|
||||||
|
id: "employee-123",
|
||||||
|
first_name: "Jamie",
|
||||||
|
last_name: "Rivera",
|
||||||
|
employee_number: "42",
|
||||||
|
active: true,
|
||||||
|
termination_date: null,
|
||||||
|
hire_date: "2026-04-20",
|
||||||
|
flat_rate: false,
|
||||||
|
rates: [],
|
||||||
|
pin: "1234",
|
||||||
|
user_email: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function TestHarness({ onFormReady }) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFormReady(form);
|
||||||
|
}, [form, onFormReady]);
|
||||||
|
|
||||||
|
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestHarness
|
||||||
|
onFormReady={(form) => {
|
||||||
|
formInstance = form;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a new employee form clean after save", async () => {
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||||
|
target: { value: "Jamie" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||||
|
target: { value: "Rivera" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||||
|
target: { value: "42" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||||
|
target: { value: "1234" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||||
|
target: { value: "2026-04-20" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: "Save Employee" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveButton.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(insertEmployeesMock).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
employees: [
|
||||||
|
expect.objectContaining({
|
||||||
|
first_name: "Jamie",
|
||||||
|
last_name: "Rivera",
|
||||||
|
employee_number: "42",
|
||||||
|
pin: "1234",
|
||||||
|
hire_date: "2026-04-20",
|
||||||
|
shopid: "shop-1"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
},
|
||||||
|
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(formInstance.isFieldsTouched()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notification.success).toHaveBeenCalledWith({
|
||||||
|
title: "Saved"
|
||||||
|
});
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
|
search: "employeeId=employee-123"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||||
|
target: { value: "Jamie" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||||
|
target: { value: "Rivera" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||||
|
target: { value: "42" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||||
|
target: { value: "1234" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||||
|
target: { value: "2026-04-20" }
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(formInstance.isFieldsTouched()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
|
search: "employeeId=new"
|
||||||
|
});
|
||||||
|
expect(notification.success).toHaveBeenCalledWith({
|
||||||
|
title: "Saved"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,16 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { alphaSort } from "../../utils/sorters";
|
import { alphaSort } from "../../utils/sorters";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
|
|
||||||
export default function ShopEmployeesListComponent({ loading, employees }) {
|
export default function ShopEmployeesListComponent({
|
||||||
|
loading,
|
||||||
|
employees,
|
||||||
|
onRequestEmployeeChange,
|
||||||
|
selectedEmployeeId
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
@@ -16,13 +23,33 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
|
|||||||
filteredInfo: { text: "" }
|
filteredInfo: { text: "" }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navigateToEmployee = (employeeId) => {
|
||||||
|
if (onRequestEmployeeChange) {
|
||||||
|
onRequestEmployeeChange(employeeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...search,
|
||||||
|
employeeId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearEmployeeSelection = () => {
|
||||||
|
const { employeeId, ...nextSearch } = search;
|
||||||
|
void employeeId;
|
||||||
|
history({
|
||||||
|
search: queryString.stringify(nextSearch)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleOnRowClick = (record) => {
|
const handleOnRowClick = (record) => {
|
||||||
if (record) {
|
if (record) {
|
||||||
search.employeeId = record.id;
|
navigateToEmployee(record.id);
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
} else {
|
} else {
|
||||||
delete search.employeeId;
|
clearEmployeeSelection();
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
@@ -30,7 +57,7 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
|
|||||||
};
|
};
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t("employees.fields.employee_number"),
|
title: t("employees.labels.employee_number_short"),
|
||||||
dataIndex: "employee_number",
|
dataIndex: "employee_number",
|
||||||
key: "employee_number",
|
key: "employee_number",
|
||||||
sorter: (a, b) => alphaSort(a.employee_number, b.employee_number),
|
sorter: (a, b) => alphaSort(a.employee_number, b.employee_number),
|
||||||
@@ -89,44 +116,39 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow
|
||||||
<ResponsiveTable
|
title={t("bodyshop.labels.employees")}
|
||||||
title={() => {
|
actions={[
|
||||||
return (
|
<Button key="new-employee" type="primary" block onClick={() => navigateToEmployee("new")}>
|
||||||
<Button
|
{t("employees.actions.new")}
|
||||||
type="primary"
|
</Button>
|
||||||
onClick={() => {
|
]}
|
||||||
search.employeeId = "new";
|
>
|
||||||
history({ search: queryString.stringify(search) });
|
{employees.length === 0 ? (
|
||||||
}}
|
<ConfigListEmptyState actionLabel={t("employees.actions.new")} />
|
||||||
>
|
) : (
|
||||||
{t("employees.actions.new")}
|
<ResponsiveTable
|
||||||
</Button>
|
loading={loading}
|
||||||
);
|
pagination={{ placement: "top" }}
|
||||||
}}
|
columns={columns}
|
||||||
loading={loading}
|
mobileColumnKeys={["employee_number", "employee_name", "active"]}
|
||||||
pagination={{ placement: "top" }}
|
rowKey="id"
|
||||||
columns={columns}
|
dataSource={employees}
|
||||||
mobileColumnKeys={["employee_number", "employee_name", "active"]}
|
rowSelection={{
|
||||||
rowKey="id"
|
onSelect: (props) => navigateToEmployee(props.id),
|
||||||
dataSource={employees}
|
type: "radio",
|
||||||
rowSelection={{
|
selectedRowKeys: [selectedEmployeeId || search.employeeId]
|
||||||
onSelect: (props) => {
|
}}
|
||||||
search.employeeId = props.id;
|
onChange={handleTableChange}
|
||||||
history({ search: queryString.stringify(search) });
|
onRow={(record) => {
|
||||||
},
|
return {
|
||||||
type: "radio",
|
onClick: () => {
|
||||||
selectedRowKeys: [search.employeeId]
|
handleOnRowClick(record);
|
||||||
}}
|
}
|
||||||
onChange={handleTableChange}
|
};
|
||||||
onRow={(record) => {
|
}}
|
||||||
return {
|
/>
|
||||||
onClick: () => {
|
)}
|
||||||
handleOnRowClick(record);
|
</LayoutFormRow>
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,101 @@
|
|||||||
|
import { Drawer, Form, Grid } from "antd";
|
||||||
import { useQuery } from "@apollo/client/react";
|
import { useQuery } from "@apollo/client/react";
|
||||||
|
import queryString from "query-string";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
|
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
|
||||||
|
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import ShopEmployeesFormComponent from "./shop-employees-form.component";
|
import ShopEmployeesFormComponent from "./shop-employees-form.component";
|
||||||
import ShopEmployeesListComponent from "./shop-employees-list.component";
|
import ShopEmployeesListComponent from "./shop-employees-list.component";
|
||||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
|
import "./shop-employees.styles.scss";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({});
|
const mapStateToProps = createStructuredSelector({});
|
||||||
|
|
||||||
function ShopEmployeesContainer() {
|
function ShopEmployeesContainer() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [isEmployeeFormDirty, setIsEmployeeFormDirty] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const search = queryString.parse(location.search);
|
||||||
const { loading, error, data } = useQuery(QUERY_EMPLOYEES, {
|
const { loading, error, data } = useQuery(QUERY_EMPLOYEES, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const hasSelectedEmployee = Boolean(search.employeeId);
|
||||||
|
|
||||||
|
const bpoints = {
|
||||||
|
xs: "100%",
|
||||||
|
sm: "100%",
|
||||||
|
md: "92%",
|
||||||
|
lg: "80%",
|
||||||
|
xl: "80%",
|
||||||
|
xxl: "80%"
|
||||||
|
};
|
||||||
|
|
||||||
|
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 hasDirtyEmployeeForm = Boolean(search.employeeId) && (isEmployeeFormDirty || form.isFieldsTouched());
|
||||||
|
const confirmCloseDirtyEmployee = useConfirmDirtyFormNavigation(hasDirtyEmployeeForm);
|
||||||
|
|
||||||
|
const navigateToEmployee = (employeeId) => {
|
||||||
|
if (employeeId === search.employeeId) return;
|
||||||
|
if (!confirmCloseDirtyEmployee()) return;
|
||||||
|
|
||||||
|
const nextSearch = { ...search, employeeId };
|
||||||
|
setIsEmployeeFormDirty(false);
|
||||||
|
navigate({
|
||||||
|
search: queryString.stringify(nextSearch)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawerClose = () => {
|
||||||
|
if (!confirmCloseDirtyEmployee()) return;
|
||||||
|
|
||||||
|
const nextSearch = { ...search };
|
||||||
|
delete nextSearch.employeeId;
|
||||||
|
setIsEmployeeFormDirty(false);
|
||||||
|
navigate({
|
||||||
|
search: queryString.stringify(nextSearch)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<RbacWrapper action="employees:page">
|
||||||
<RbacWrapper action="employees:page">
|
<div className="shop-employees-layout">
|
||||||
<ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} />
|
<div className="shop-employees-layout__list">
|
||||||
<ShopEmployeesFormComponent />
|
<ShopEmployeesListComponent
|
||||||
</RbacWrapper>
|
employees={data ? data.employees : []}
|
||||||
</div>
|
loading={loading}
|
||||||
|
onRequestEmployeeChange={navigateToEmployee}
|
||||||
|
selectedEmployeeId={search.employeeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Drawer
|
||||||
|
open={hasSelectedEmployee}
|
||||||
|
destroyOnHidden
|
||||||
|
placement="right"
|
||||||
|
size={drawerPercentage}
|
||||||
|
onClose={handleDrawerClose}
|
||||||
|
>
|
||||||
|
{hasSelectedEmployee ? (
|
||||||
|
<ShopEmployeesFormComponent form={form} onDirtyChange={setIsEmployeeFormDirty} isDirty={isEmployeeFormDirty} />
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</RbacWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.shop-employees-layout {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-employees-layout__list {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
304
client/src/components/shop-info/shop-info.color.utils.js
Normal file
304
client/src/components/shop-info/shop-info.color.utils.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Default translucent card color used for tinting card surfaces when no specific color is provided.
|
||||||
|
* @type {{r: number, g: number, b: number, a: number}}
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TRANSLUCENT_CARD_COLOR = {
|
||||||
|
r: 22,
|
||||||
|
g: 119,
|
||||||
|
b: 255,
|
||||||
|
a: 0.5
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds a color channel value to two decimal places.
|
||||||
|
* @param value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
const roundColorChannel = (value) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds a tint percentage value to two decimal places.
|
||||||
|
* @param value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
const roundTintPercentage = (value) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps an alpha value to the range [0, 1] and rounds it to two decimal places.
|
||||||
|
* @param value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
const clampAlpha = (value) => {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue)) return 1;
|
||||||
|
if (numericValue <= 0) return 0;
|
||||||
|
if (numericValue >= 1) return 1;
|
||||||
|
|
||||||
|
return numericValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an RGB color object to a hexadecimal color string.
|
||||||
|
* @param param0
|
||||||
|
* @param param0.r
|
||||||
|
* @param param0.g
|
||||||
|
* @param param0.b
|
||||||
|
* @returns {`#${string}`}
|
||||||
|
*/
|
||||||
|
const rgbToHex = ({ r, g, b }) =>
|
||||||
|
`#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an RGB color object to an HSL color object.
|
||||||
|
* @param param0
|
||||||
|
* @param param0.r
|
||||||
|
* @param param0.g
|
||||||
|
* @param param0.b
|
||||||
|
* @param param0.a
|
||||||
|
* @returns {{h: number, s: number, l: number, a: number}|{h: number, s: number, l: number, a: number}}
|
||||||
|
*/
|
||||||
|
const rgbToHsl = ({ r, g, b, a = 1 }) => {
|
||||||
|
const red = r / 255;
|
||||||
|
const green = g / 255;
|
||||||
|
const blue = b / 255;
|
||||||
|
const max = Math.max(red, green, blue);
|
||||||
|
const min = Math.min(red, green, blue);
|
||||||
|
const delta = max - min;
|
||||||
|
const lightness = (max + min) / 2;
|
||||||
|
|
||||||
|
if (delta === 0) {
|
||||||
|
return { h: 0, s: 0, l: roundColorChannel(lightness), a };
|
||||||
|
}
|
||||||
|
|
||||||
|
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||||
|
let hue;
|
||||||
|
|
||||||
|
switch (max) {
|
||||||
|
case red:
|
||||||
|
hue = (green - blue) / delta + (green < blue ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case green:
|
||||||
|
hue = (blue - red) / delta + 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
hue = (red - green) / delta + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: roundColorChannel(hue * 60),
|
||||||
|
s: roundColorChannel(saturation),
|
||||||
|
l: roundColorChannel(lightness),
|
||||||
|
a
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an RGB color object to an HSV color object.
|
||||||
|
* @param param0
|
||||||
|
* @param param0.r
|
||||||
|
* @param param0.g
|
||||||
|
* @param param0.b
|
||||||
|
* @param param0.a
|
||||||
|
* @returns {{h: number, s: number, v: number, a: number}}
|
||||||
|
*/
|
||||||
|
const rgbToHsv = ({ r, g, b, a = 1 }) => {
|
||||||
|
const red = r / 255;
|
||||||
|
const green = g / 255;
|
||||||
|
const blue = b / 255;
|
||||||
|
const max = Math.max(red, green, blue);
|
||||||
|
const min = Math.min(red, green, blue);
|
||||||
|
const delta = max - min;
|
||||||
|
const saturation = max === 0 ? 0 : delta / max;
|
||||||
|
let hue = 0;
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
switch (max) {
|
||||||
|
case red:
|
||||||
|
hue = (green - blue) / delta + (green < blue ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case green:
|
||||||
|
hue = (blue - red) / delta + 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
hue = (red - green) / delta + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: roundColorChannel(hue * 60),
|
||||||
|
s: roundColorChannel(saturation),
|
||||||
|
v: roundColorChannel(max),
|
||||||
|
a
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a comprehensive color value object for a color picker component based on an input RGB color object.
|
||||||
|
* @param rgb
|
||||||
|
* @returns {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}}
|
||||||
|
*/
|
||||||
|
const buildPickerColorValue = (rgb) => {
|
||||||
|
const hsl = rgbToHsl(rgb);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex: rgbToHex(rgb),
|
||||||
|
rgb: { ...rgb },
|
||||||
|
hsl,
|
||||||
|
hsv: rgbToHsv(rgb),
|
||||||
|
oldHue: hsl.h,
|
||||||
|
source: "rgb"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default color value object for the color picker component, derived from the default translucent card color.
|
||||||
|
* @type {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}}
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TRANSLUCENT_PICKER_COLOR = buildPickerColorValue(DEFAULT_TRANSLUCENT_CARD_COLOR);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a color string that may be a JSON representation of a color object. If the string is valid JSON and represents
|
||||||
|
* a color, it returns the parsed object; otherwise, it returns the original string.
|
||||||
|
* @param color
|
||||||
|
* @returns {*|string}
|
||||||
|
*/
|
||||||
|
const parseJsonColorString = (color) => {
|
||||||
|
if (typeof color !== "string") return color;
|
||||||
|
|
||||||
|
const trimmedColor = color.trim();
|
||||||
|
if (!trimmedColor.startsWith("{") && !trimmedColor.startsWith("[")) return color;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmedColor);
|
||||||
|
} catch {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a hexadecimal color string (e.g., "#RRGGBB" or "#RRGGBBAA") and returns an object containing the corresponding
|
||||||
|
* RGB color value and alpha transparency. Supports both 3/4-digit and 6/8-digit hex formats.
|
||||||
|
* @param color
|
||||||
|
* @returns {{colorCssValue: string, alpha: number}|null}
|
||||||
|
*/
|
||||||
|
const parseHexColor = (color) => {
|
||||||
|
if (typeof color !== "string") return null;
|
||||||
|
|
||||||
|
const normalizedHex = color.trim().replace(/^#/, "");
|
||||||
|
|
||||||
|
if (![3, 4, 6, 8].includes(normalizedHex.length) || /[^0-9a-f]/i.test(normalizedHex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedHex =
|
||||||
|
normalizedHex.length <= 4
|
||||||
|
? normalizedHex
|
||||||
|
.split("")
|
||||||
|
.map((character) => `${character}${character}`)
|
||||||
|
.join("")
|
||||||
|
: normalizedHex;
|
||||||
|
|
||||||
|
const hasAlpha = expandedHex.length === 8;
|
||||||
|
const red = Number.parseInt(expandedHex.slice(0, 2), 16);
|
||||||
|
const green = Number.parseInt(expandedHex.slice(2, 4), 16);
|
||||||
|
const blue = Number.parseInt(expandedHex.slice(4, 6), 16);
|
||||||
|
const alpha = hasAlpha ? Number.parseInt(expandedHex.slice(6, 8), 16) / 255 : 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorCssValue: `rgb(${red}, ${green}, ${blue})`,
|
||||||
|
alpha: clampAlpha(alpha)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an RGB or RGBA color string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") and returns an object
|
||||||
|
* containing the corresponding RGB color value and alpha transparency. Supports both integer and percentage formats for
|
||||||
|
* color channels and alpha.
|
||||||
|
* @param color
|
||||||
|
* @returns {{colorCssValue: string, alpha: number}|null}
|
||||||
|
*/
|
||||||
|
const parseRgbColor = (color) => {
|
||||||
|
if (typeof color !== "string") return null;
|
||||||
|
|
||||||
|
const rgbMatch = color.trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
|
||||||
|
|
||||||
|
if (!rgbMatch) return null;
|
||||||
|
|
||||||
|
const [, red, green, blue, alpha = 1] = rgbMatch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorCssValue: `rgb(${red}, ${green}, ${blue})`,
|
||||||
|
alpha: clampAlpha(alpha)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a color input into a consistent descriptor object containing a CSS color value and an alpha transparency
|
||||||
|
* level.
|
||||||
|
* @param color
|
||||||
|
* @returns {{colorCssValue: string, alpha: number}|{colorCssValue: string, alpha: number}|*|{colorCssValue: string, alpha: number}|null}
|
||||||
|
*/
|
||||||
|
const getNormalizedColorDescriptor = (color) => {
|
||||||
|
if (!color) return null;
|
||||||
|
|
||||||
|
const normalizedColor = parseJsonColorString(color);
|
||||||
|
|
||||||
|
if (typeof normalizedColor === "string") {
|
||||||
|
return (
|
||||||
|
parseHexColor(normalizedColor) ||
|
||||||
|
parseRgbColor(normalizedColor) || {
|
||||||
|
colorCssValue: normalizedColor,
|
||||||
|
alpha: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof normalizedColor === "object" && normalizedColor.rgb) {
|
||||||
|
return getNormalizedColorDescriptor(normalizedColor.rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof normalizedColor === "object" && typeof normalizedColor.hex === "string") {
|
||||||
|
return getNormalizedColorDescriptor(normalizedColor.hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof normalizedColor === "object" &&
|
||||||
|
normalizedColor.r !== undefined &&
|
||||||
|
normalizedColor.g !== undefined &&
|
||||||
|
normalizedColor.b !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
colorCssValue: `rgb(${normalizedColor.r}, ${normalizedColor.g}, ${normalizedColor.b})`,
|
||||||
|
alpha: clampAlpha(normalizedColor.a)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates CSS styles for tinting card surfaces based on a provided color input. The function normalizes the input
|
||||||
|
* color,
|
||||||
|
* @param color
|
||||||
|
* @returns {{surfaceBg: string, surfaceHeaderBg: string, surfaceBorderColor: string}|{}}
|
||||||
|
*/
|
||||||
|
export const getTintedCardSurfaceStyles = (color) => {
|
||||||
|
const normalizedColor = getNormalizedColorDescriptor(color);
|
||||||
|
if (!normalizedColor?.colorCssValue) return {};
|
||||||
|
|
||||||
|
const tintStrength = clampAlpha(normalizedColor.alpha);
|
||||||
|
if (tintStrength === 0) return {};
|
||||||
|
|
||||||
|
const backgroundTint = roundTintPercentage(10 * tintStrength);
|
||||||
|
const headerTint = roundTintPercentage(18 * tintStrength);
|
||||||
|
const borderTint = roundTintPercentage(30 * tintStrength);
|
||||||
|
|
||||||
|
return {
|
||||||
|
surfaceBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${backgroundTint}%, var(--imex-form-surface))`,
|
||||||
|
surfaceHeaderBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${headerTint}%, var(--imex-form-surface-head))`,
|
||||||
|
surfaceBorderColor: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${borderTint}%, var(--imex-form-surface-border))`
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getTintedCardSurfaceStyles } from "./shop-info.color.utils";
|
||||||
|
|
||||||
|
describe("shop info color utilities", () => {
|
||||||
|
it("scales card tint intensity with alpha for plain rgba values", () => {
|
||||||
|
expect(
|
||||||
|
getTintedCardSurfaceStyles({
|
||||||
|
r: 22,
|
||||||
|
g: 119,
|
||||||
|
b: 255,
|
||||||
|
a: 0.5
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
surfaceBg: "color-mix(in srgb, rgb(22, 119, 255) 5%, var(--imex-form-surface))",
|
||||||
|
surfaceHeaderBg: "color-mix(in srgb, rgb(22, 119, 255) 9%, var(--imex-form-surface-head))",
|
||||||
|
surfaceBorderColor: "color-mix(in srgb, rgb(22, 119, 255) 15%, var(--imex-form-surface-border))"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no tint when the selected color alpha is zero", () => {
|
||||||
|
expect(
|
||||||
|
getTintedCardSurfaceStyles({
|
||||||
|
hex: "#1677ff",
|
||||||
|
rgb: {
|
||||||
|
r: 22,
|
||||||
|
g: 119,
|
||||||
|
b: 255,
|
||||||
|
a: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports legacy JSON-stringified picker values", () => {
|
||||||
|
expect(
|
||||||
|
getTintedCardSurfaceStyles(
|
||||||
|
JSON.stringify({
|
||||||
|
rgb: {
|
||||||
|
r: 255,
|
||||||
|
g: 0,
|
||||||
|
b: 0,
|
||||||
|
a: 0.25
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
surfaceBg: "color-mix(in srgb, rgb(255, 0, 0) 2.5%, var(--imex-form-surface))",
|
||||||
|
surfaceHeaderBg: "color-mix(in srgb, rgb(255, 0, 0) 4.5%, var(--imex-form-surface-head))",
|
||||||
|
surfaceBorderColor: "color-mix(in srgb, rgb(255, 0, 0) 7.5%, var(--imex-form-surface-border))"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Card, Tabs } from "antd";
|
import { Button, Card, Tabs } from "antd";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
@@ -21,6 +22,7 @@ import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycen
|
|||||||
import ShopInfoRoGuard from "./shop-info.roguard.component";
|
import ShopInfoRoGuard from "./shop-info.roguard.component";
|
||||||
import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
|
import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
|
||||||
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
|
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
|
||||||
|
import ShopInfoSectionNavigator from "./shop-info.section-navigator.component.jsx";
|
||||||
import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
|
import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
|
||||||
import ShopInfoTaskPresets from "./shop-info.task-presets.component";
|
import ShopInfoTaskPresets from "./shop-info.task-presets.component";
|
||||||
import ShopInfoIntellipay from "./shop-intellipay-config.component";
|
import ShopInfoIntellipay from "./shop-intellipay-config.component";
|
||||||
@@ -33,7 +35,7 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
|
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
|
||||||
|
|
||||||
export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
|
export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
|
||||||
const {
|
const {
|
||||||
treatments: { CriticalPartsScanning, Enhanced_Payroll }
|
treatments: { CriticalPartsScanning, Enhanced_Payroll }
|
||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
@@ -47,6 +49,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
|
|||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const search = queryString.parse(location.search);
|
const search = queryString.parse(location.search);
|
||||||
|
const tabsRef = useRef(null);
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
@@ -154,23 +157,35 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
|
|||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
];
|
];
|
||||||
|
const activeTabKey = search.subtab || tabItems[0]?.key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
title={<ShopInfoSectionNavigator tabsRef={tabsRef} activeTabKey={activeTabKey} />}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button">
|
<Button
|
||||||
{t("general.actions.save")}
|
type="primary"
|
||||||
|
disabled={!isDirty || saveLoading}
|
||||||
|
loading={saveLoading}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
id="shop-info-save-button"
|
||||||
|
style={{ minWidth: 210 }}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.save_shop_information")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs
|
<div ref={tabsRef}>
|
||||||
defaultActiveKey={search.subtab}
|
<Tabs
|
||||||
onChange={(key) =>
|
activeKey={activeTabKey}
|
||||||
history({
|
onChange={(key) =>
|
||||||
search: `?tab=${search.tab}&subtab=${key}`
|
history({
|
||||||
})
|
search: `?tab=${search.tab}&subtab=${key}`
|
||||||
}
|
})
|
||||||
items={tabItems}
|
}
|
||||||
/>
|
items={tabItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, Typography } from "antd";
|
import { Card } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -15,9 +15,8 @@ function ShopInfoConsentComponent({ bodyshop }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card title={t("settings.title")}>
|
||||||
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
|
<PhoneNumberConsentList bodyshop={bodyshop} />
|
||||||
{<PhoneNumberConsentList bodyshop={bodyshop} />}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { Form } from "antd";
|
import { Form } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
@@ -15,6 +15,7 @@ import { FEATURE_CONFIGS, useFormDataPreservation } from "./useFormDataPreservat
|
|||||||
export default function ShopInfoContainer() {
|
export default function ShopInfoContainer() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isShopInfoDirty, setIsShopInfoDirty] = useState(false);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [updateBodyshop] = useMutation(UPDATE_SHOP);
|
const [updateBodyshop] = useMutation(UPDATE_SHOP);
|
||||||
const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, {
|
const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, {
|
||||||
@@ -33,7 +34,10 @@ export default function ShopInfoContainer() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters);
|
const combinedFeatureConfig = useMemo(
|
||||||
|
() => combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Use form data preservation for all shop-info features
|
// Use form data preservation for all shop-info features
|
||||||
const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
|
const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
|
||||||
@@ -51,7 +55,10 @@ export default function ShopInfoContainer() {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
notification.success({ title: t("bodyshop.successes.save") });
|
notification.success({ title: t("bodyshop.successes.save") });
|
||||||
refetch().then(() => form.resetFields());
|
refetch().then(() => {
|
||||||
|
form.resetFields();
|
||||||
|
setIsShopInfoDirty(false);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
notification.error({
|
notification.error({
|
||||||
@@ -66,6 +73,7 @@ export default function ShopInfoContainer() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
// After reset, re-apply hidden field preservation so values aren't wiped
|
// After reset, re-apply hidden field preservation so values aren't wiped
|
||||||
preserveHiddenFormData();
|
preserveHiddenFormData();
|
||||||
|
setIsShopInfoDirty(false);
|
||||||
}, [data, form, preserveHiddenFormData]);
|
}, [data, form, preserveHiddenFormData]);
|
||||||
|
|
||||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||||
@@ -76,6 +84,9 @@ export default function ShopInfoContainer() {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
|
onValuesChange={() => {
|
||||||
|
setIsShopInfoDirty(form.isFieldsTouched());
|
||||||
|
}}
|
||||||
initialValues={
|
initialValues={
|
||||||
data
|
data
|
||||||
? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod
|
? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod
|
||||||
@@ -99,8 +110,8 @@ export default function ShopInfoContainer() {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormsFieldChanged form={form} />
|
<FormsFieldChanged form={form} onDirtyChange={setIsShopInfoDirty} />
|
||||||
<ShopInfoComponent form={form} saveLoading={saveLoading} />
|
<ShopInfoComponent form={form} saveLoading={saveLoading} isDirty={isShopInfoDirty} />
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,19 @@ import styled from "styled-components";
|
|||||||
import { TemplateList } from "../../utils/TemplateConstants";
|
import { TemplateList } from "../../utils/TemplateConstants";
|
||||||
import ConfigFormTypes from "../config-form-components/config-form-types";
|
import ConfigFormTypes from "../config-form-components/config-form-types";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
INLINE_TITLE_SEPARATOR_STYLE,
|
||||||
|
INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
|
|
||||||
const SelectorDiv = styled.div`
|
const SelectorDiv = styled.div`
|
||||||
.ant-form-item .ant-select {
|
.ant-form-item .ant-select {
|
||||||
@@ -19,306 +31,386 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
|
|||||||
const TemplateListGenerated = TemplateList();
|
const TemplateListGenerated = TemplateList();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<LayoutFormRow header={t("bodyshop.labels.intakechecklist")} id="intakechecklist">
|
|
||||||
<Form.List name={["intakechecklist", "form"]}>
|
|
||||||
{(fields, { add, remove, move }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<Form.Item key={field.key}>
|
|
||||||
<LayoutFormRow noDivider>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.name")}
|
|
||||||
key={`${index}name`}
|
|
||||||
name={[field.name, "name"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.type")}
|
|
||||||
key={`${index}type`}
|
|
||||||
name={[field.name, "type"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.label")}
|
|
||||||
key={`${index}label`}
|
|
||||||
name={[field.name, "label"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item shouldUpdate>
|
|
||||||
{() => {
|
|
||||||
if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider") return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.min")}
|
|
||||||
key={`${index}min`}
|
|
||||||
name={[field.name, "min"]}
|
|
||||||
dependencies={[[field.name, "type"]]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.max")}
|
|
||||||
key={`${index}max`}
|
|
||||||
name={[field.name, "max"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.required")}
|
|
||||||
key={`${index}required`}
|
|
||||||
name={[field.name, "required"]}
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("general.actions.add")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.List>
|
|
||||||
</LayoutFormRow>
|
|
||||||
<SelectorDiv>
|
<SelectorDiv>
|
||||||
<Form.Item
|
<LayoutFormRow header={t("bodyshop.labels.intake_delivery")} id="intake-delivery">
|
||||||
name={["intakechecklist", "templates"]}
|
<Form.Item
|
||||||
label={t("bodyshop.fields.intake.templates")}
|
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
rules={[
|
name={["intakechecklist", "templates"]}
|
||||||
{
|
label={t("bodyshop.fields.intake.templates")}
|
||||||
required: true,
|
rules={[
|
||||||
//message: t("general.validation.required"),
|
{
|
||||||
type: "array"
|
required: true,
|
||||||
}
|
//message: t("general.validation.required"),
|
||||||
]}
|
type: "array"
|
||||||
>
|
}
|
||||||
<Select
|
]}
|
||||||
mode="multiple"
|
>
|
||||||
options={Object.keys(TemplateListGenerated).map((i) => ({
|
<Select
|
||||||
value: TemplateListGenerated[i].key,
|
mode="multiple"
|
||||||
label: TemplateListGenerated[i].title
|
options={Object.keys(TemplateListGenerated).map((i) => ({
|
||||||
}))}
|
value: TemplateListGenerated[i].key,
|
||||||
/>
|
label: TemplateListGenerated[i].title
|
||||||
</Form.Item>
|
}))}
|
||||||
<Form.Item
|
/>
|
||||||
name={["intakechecklist", "next_contact_hours"]}
|
</Form.Item>
|
||||||
label={t("bodyshop.fields.intake.next_contact_hours")}
|
<Form.Item
|
||||||
>
|
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
<InputNumber min={0} precision={0} />
|
name={["deliverchecklist", "templates"]}
|
||||||
</Form.Item>
|
label={t("bodyshop.fields.deliver.templates")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
type: "array"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={Object.keys(TemplateListGenerated).map((i) => ({
|
||||||
|
value: TemplateListGenerated[i].key,
|
||||||
|
label: TemplateListGenerated[i].title
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
col={{ xs: 24, sm: 10, md: 8, lg: 8, xl: 8, xxl: 8 }}
|
||||||
|
name={["intakechecklist", "next_contact_hours"]}
|
||||||
|
label={t("bodyshop.fields.intake.next_contact_hours")}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={0} suffix="hrs" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
col={{ xs: 24, sm: 14, md: 16, lg: 16, xl: 16, xxl: 16 }}
|
||||||
|
name={["deliverchecklist", "actual_delivery"]}
|
||||||
|
label={t("bodyshop.fields.deliver.require_actual_delivery_date")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
</SelectorDiv>
|
</SelectorDiv>
|
||||||
|
<Form.List name={["intakechecklist", "form"]}>
|
||||||
<LayoutFormRow header={t("bodyshop.labels.deliverchecklist")} id="deliverchecklist">
|
{(fields, { add, remove, move }) => {
|
||||||
<Form.List name={["deliverchecklist", "form"]}>
|
return (
|
||||||
{(fields, { add, remove, move }) => {
|
<LayoutFormRow
|
||||||
return (
|
header={t("bodyshop.labels.intakechecklist")}
|
||||||
|
id="intakechecklist"
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-intake-checklist-item"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
add();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.add_intake_checklist_item")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.length === 0 ? (
|
||||||
<Form.Item key={field.key}>
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_intake_checklist_item")} />
|
||||||
<LayoutFormRow noDivider>
|
) : (
|
||||||
<Form.Item
|
fields.map((field, index) => {
|
||||||
label={t("jobs.fields.intake.name")}
|
return (
|
||||||
key={`${index}named`}
|
<Form.Item noStyle key={field.key}>
|
||||||
name={[field.name, "name"]}
|
<InlineValidatedFormRow
|
||||||
rules={[
|
form={form}
|
||||||
{
|
errorNames={[["intakechecklist", "form", field.name, "name"]]}
|
||||||
required: true
|
noDivider
|
||||||
//message: t("general.validation.required"),
|
title={
|
||||||
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.name")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "name"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("jobs.fields.intake.name")}
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.required")}</div>
|
||||||
|
<Form.Item noStyle name={[field.name, "required"]} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
]}
|
wrapTitle
|
||||||
>
|
extra={
|
||||||
<Input />
|
<Space align="center" size="small">
|
||||||
</Form.Item>
|
<Button
|
||||||
|
type="text"
|
||||||
<Form.Item
|
danger
|
||||||
label={t("jobs.fields.intake.type")}
|
icon={<DeleteFilled />}
|
||||||
key={`${index}typed`}
|
onClick={() => {
|
||||||
name={[field.name, "type"]}
|
remove(field.name);
|
||||||
rules={[
|
}}
|
||||||
{
|
/>
|
||||||
required: true
|
<FormListMoveArrows
|
||||||
//message: t("general.validation.required"),
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
]}
|
>
|
||||||
>
|
<Form.Item
|
||||||
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
label={t("jobs.fields.intake.type")}
|
||||||
</Form.Item>
|
key={`${index}type`}
|
||||||
|
name={[field.name, "type"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.label")}
|
||||||
|
key={`${index}label`}
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item shouldUpdate>
|
||||||
label={t("jobs.fields.intake.label")}
|
{() => {
|
||||||
key={`${index}labeld`}
|
if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider")
|
||||||
name={[field.name, "label"]}
|
return null;
|
||||||
rules={[
|
return (
|
||||||
{
|
<>
|
||||||
required: true
|
<Form.Item
|
||||||
//message: t("general.validation.required"),
|
label={t("jobs.fields.intake.min")}
|
||||||
}
|
key={`${index}min`}
|
||||||
]}
|
name={[field.name, "min"]}
|
||||||
>
|
dependencies={[[field.name, "type"]]}
|
||||||
<Input />
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.max")}
|
||||||
|
key={`${index}max`}
|
||||||
|
name={[field.name, "max"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
);
|
||||||
<Form.Item shouldUpdate>
|
})
|
||||||
{() => {
|
)}
|
||||||
if (form.getFieldValue(["deliverchecklist", "form", index, "type"]) !== "slider") return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.min")}
|
|
||||||
key={`${index}mind`}
|
|
||||||
name={[field.name, "min"]}
|
|
||||||
dependencies={[[field.name, "type"]]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: form.getFieldValue([field.name, "type"]) === "slider"
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.max")}
|
|
||||||
key={`${index}maxd`}
|
|
||||||
name={[field.name, "max"]}
|
|
||||||
dependencies={[[field.name, "type"]]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: form.getFieldValue([field.name, "type"]) === "slider"
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.intake.required")}
|
|
||||||
key={`${index}requiredd`}
|
|
||||||
name={[field.name, "required"]}
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("general.actions.add")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</LayoutFormRow>
|
||||||
}}
|
);
|
||||||
</Form.List>
|
}}
|
||||||
</LayoutFormRow>
|
</Form.List>
|
||||||
<SelectorDiv>
|
<Form.List name={["deliverchecklist", "form"]}>
|
||||||
<Form.Item
|
{(fields, { add, remove, move }) => {
|
||||||
name={["deliverchecklist", "templates"]}
|
return (
|
||||||
label={t("bodyshop.fields.deliver.templates")}
|
<LayoutFormRow
|
||||||
rules={[
|
header={t("bodyshop.labels.deliverchecklist")}
|
||||||
{
|
id="deliverchecklist"
|
||||||
required: true,
|
actions={[
|
||||||
//message: t("general.validation.required"),
|
<Button
|
||||||
type: "array"
|
key="add-delivery-checklist-item"
|
||||||
}
|
type="primary"
|
||||||
]}
|
block
|
||||||
>
|
onClick={() => {
|
||||||
<Select
|
add();
|
||||||
mode="multiple"
|
}}
|
||||||
options={Object.keys(TemplateListGenerated).map((i) => ({
|
>
|
||||||
value: TemplateListGenerated[i].key,
|
{t("bodyshop.actions.add_delivery_checklist_item")}
|
||||||
label: TemplateListGenerated[i].title
|
</Button>
|
||||||
}))}
|
]}
|
||||||
/>
|
>
|
||||||
</Form.Item>
|
<div>
|
||||||
<Form.Item
|
{fields.length === 0 ? (
|
||||||
name={["deliverchecklist", "actual_delivery"]}
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_delivery_checklist_item")} />
|
||||||
label={t("bodyshop.fields.deliver.require_actual_delivery_date")}
|
) : (
|
||||||
rules={[
|
fields.map((field, index) => {
|
||||||
{
|
return (
|
||||||
required: true
|
<Form.Item noStyle key={field.key}>
|
||||||
//message: t("general.validation.required"),
|
<InlineValidatedFormRow
|
||||||
}
|
form={form}
|
||||||
]}
|
errorNames={[["deliverchecklist", "form", field.name, "name"]]}
|
||||||
>
|
noDivider
|
||||||
<Switch />
|
title={
|
||||||
</Form.Item>
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
</SelectorDiv>
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.name")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "name"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("jobs.fields.intake.name")}
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.required")}</div>
|
||||||
|
<Form.Item noStyle name={[field.name, "required"]} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.type")}
|
||||||
|
key={`${index}typed`}
|
||||||
|
name={[field.name, "type"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.label")}
|
||||||
|
key={`${index}labeld`}
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item shouldUpdate>
|
||||||
|
{() => {
|
||||||
|
if (form.getFieldValue(["deliverchecklist", "form", index, "type"]) !== "slider")
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.min")}
|
||||||
|
key={`${index}mind`}
|
||||||
|
name={[field.name, "min"]}
|
||||||
|
dependencies={[[field.name, "type"]]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: form.getFieldValue([field.name, "type"]) === "slider"
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.intake.max")}
|
||||||
|
key={`${index}maxd`}
|
||||||
|
name={[field.name, "max"]}
|
||||||
|
dependencies={[[field.name, "type"]]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: form.getFieldValue([field.name, "type"]) === "slider"
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.List>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,344 +3,392 @@ import { Button, Form, Input, Space } from "antd";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
|
|
||||||
export default function ShopInfoLaborRates() {
|
export default function ShopInfoLaborRates() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
|
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
|
||||||
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
|
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
|
||||||
<CurrencyInput min={0} />
|
<CurrencyInput prefix="$" min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
|
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
|
||||||
<CurrencyInput min={0} />
|
<CurrencyInput prefix="$" min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<LayoutFormRow header={t("bodyshop.labels.laborrates")}>
|
<Form.List name={["md_labor_rates"]}>
|
||||||
<Form.List name={["md_labor_rates"]}>
|
{(fields, { add, remove, move }) => {
|
||||||
{(fields, { add, remove, move }) => {
|
return (
|
||||||
return (
|
<LayoutFormRow
|
||||||
|
header={t("bodyshop.labels.laborrates")}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-labor-rate"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
add();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.newlaborrate")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.length === 0 ? (
|
||||||
<Form.Item key={field.key}>
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.newlaborrate")} />
|
||||||
<LayoutFormRow noDivider={index === 0}>
|
) : (
|
||||||
<Form.Item
|
fields.map((field, index) => {
|
||||||
label={t("jobs.fields.labor_rate_desc")}
|
return (
|
||||||
key={`${index}rate_label`}
|
<Form.Item noStyle key={field.key}>
|
||||||
name={[field.name, "rate_label"]}
|
<InlineValidatedFormRow
|
||||||
rules={[
|
form={form}
|
||||||
{
|
errorNames={[["md_labor_rates", field.name, "rate_label"]]}
|
||||||
required: true
|
noDivider={index === 0}
|
||||||
//message: t("general.validation.required"),
|
title={
|
||||||
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.labor_rate_desc")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "rate_label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("jobs.fields.labor_rate_desc")}
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
]}
|
wrapTitle
|
||||||
>
|
extra={
|
||||||
<Input />
|
<Space align="center" size="small">
|
||||||
</Form.Item>
|
<Button
|
||||||
<Form.Item
|
type="text"
|
||||||
label={t("jobs.fields.rate_laa")}
|
danger
|
||||||
key={`${index}rate_laa`}
|
icon={<DeleteFilled />}
|
||||||
name={[field.name, "rate_laa"]}
|
onClick={() => {
|
||||||
rules={[
|
remove(field.name);
|
||||||
{
|
}}
|
||||||
required: true
|
/>
|
||||||
//message: t("general.validation.required"),
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
]}
|
>
|
||||||
>
|
<Form.Item
|
||||||
<CurrencyInput min={0} />
|
label={t("jobs.fields.rate_laa")}
|
||||||
</Form.Item>
|
key={`${index}rate_laa`}
|
||||||
<Form.Item
|
name={[field.name, "rate_laa"]}
|
||||||
label={t("jobs.fields.rate_lab")}
|
rules={[
|
||||||
key={`${index}rate_lab`}
|
{
|
||||||
name={[field.name, "rate_lab"]}
|
required: true
|
||||||
rules={[
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lab")}
|
||||||
|
key={`${index}rate_lab`}
|
||||||
|
name={[field.name, "rate_lab"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lad")}
|
||||||
|
key={`${index}rate_lad`}
|
||||||
|
name={[field.name, "rate_lad"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lae")}
|
||||||
|
key={`${index}rate_lae`}
|
||||||
|
name={[field.name, "rate_lae"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_laf")}
|
||||||
|
key={`${index}rate_laf`}
|
||||||
|
name={[field.name, "rate_laf"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lag")}
|
||||||
|
key={`${index}rate_lag`}
|
||||||
|
name={[field.name, "rate_lag"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lam")}
|
||||||
|
key={`${index}rate_lam`}
|
||||||
|
name={[field.name, "rate_lam"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_lar")}
|
||||||
|
key={`${index}rate_lar`}
|
||||||
|
name={[field.name, "rate_lar"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_las")}
|
||||||
|
key={`${index}rate_las`}
|
||||||
|
name={[field.name, "rate_las"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_la1")}
|
||||||
|
key={`${index}rate_la1`}
|
||||||
|
name={[field.name, "rate_la1"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_la2")}
|
||||||
|
key={`${index}rate_la2`}
|
||||||
|
name={[field.name, "rate_la2"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_la3")}
|
||||||
|
key={`${index}rate_la3`}
|
||||||
|
name={[field.name, "rate_la3"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_la4")}
|
||||||
|
key={`${index}rate_la4`}
|
||||||
|
name={[field.name, "rate_la4"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_mash")}
|
||||||
|
key={`${index}rate_mash`}
|
||||||
|
name={[field.name, "rate_mash"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_mapa")}
|
||||||
|
key={`${index}rate_mapa`}
|
||||||
|
name={[field.name, "rate_mapa"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_ma2s")}
|
||||||
|
key={`${index}rate_ma2s`}
|
||||||
|
name={[field.name, "rate_ma2s"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_ma3s")}
|
||||||
|
key={`${index}rate_ma3s`}
|
||||||
|
name={[field.name, "rate_ma3s"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
{
|
{
|
||||||
required: true
|
// <Form.Item
|
||||||
//message: t("general.validation.required"),
|
// label={t("jobs.fields.rate_mabl")}
|
||||||
|
// key={`${index}rate_mabl`}
|
||||||
|
// name={[field.name, "rate_mabl"]}
|
||||||
|
// rules={[
|
||||||
|
// {
|
||||||
|
// required: true,
|
||||||
|
// //message: t("general.validation.required"),
|
||||||
|
// },
|
||||||
|
// ]}
|
||||||
|
// >
|
||||||
|
// <CurrencyInput min={0} />
|
||||||
|
// </Form.Item>
|
||||||
|
// <Form.Item
|
||||||
|
// label={t("jobs.fields.rate_macs")}
|
||||||
|
// key={`${index}rate_macs`}
|
||||||
|
// name={[field.name, "rate_macs"]}
|
||||||
|
// rules={[
|
||||||
|
// {
|
||||||
|
// required: true,
|
||||||
|
// //message: t("general.validation.required"),
|
||||||
|
// },
|
||||||
|
// ]}
|
||||||
|
// >
|
||||||
|
// <CurrencyInput min={0} />
|
||||||
|
// </Form.Item>
|
||||||
}
|
}
|
||||||
]}
|
<Form.Item
|
||||||
>
|
label={t("jobs.fields.rate_matd")}
|
||||||
<CurrencyInput min={0} />
|
key={`${index}rate_matd`}
|
||||||
|
name={[field.name, "rate_matd"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_mahw")}
|
||||||
|
key={`${index}rate_mahw`}
|
||||||
|
name={[field.name, "rate_mahw"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CurrencyInput prefix="$" min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
);
|
||||||
label={t("jobs.fields.rate_lad")}
|
})
|
||||||
key={`${index}rate_lad`}
|
)}
|
||||||
name={[field.name, "rate_lad"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_lae")}
|
|
||||||
key={`${index}rate_lae`}
|
|
||||||
name={[field.name, "rate_lae"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_laf")}
|
|
||||||
key={`${index}rate_laf`}
|
|
||||||
name={[field.name, "rate_laf"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_lag")}
|
|
||||||
key={`${index}rate_lag`}
|
|
||||||
name={[field.name, "rate_lag"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_lam")}
|
|
||||||
key={`${index}rate_lam`}
|
|
||||||
name={[field.name, "rate_lam"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_lar")}
|
|
||||||
key={`${index}rate_lar`}
|
|
||||||
name={[field.name, "rate_lar"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_las")}
|
|
||||||
key={`${index}rate_las`}
|
|
||||||
name={[field.name, "rate_las"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_la1")}
|
|
||||||
key={`${index}rate_la1`}
|
|
||||||
name={[field.name, "rate_la1"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_la2")}
|
|
||||||
key={`${index}rate_la2`}
|
|
||||||
name={[field.name, "rate_la2"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_la3")}
|
|
||||||
key={`${index}rate_la3`}
|
|
||||||
name={[field.name, "rate_la3"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_la4")}
|
|
||||||
key={`${index}rate_la4`}
|
|
||||||
name={[field.name, "rate_la4"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_mash")}
|
|
||||||
key={`${index}rate_mash`}
|
|
||||||
name={[field.name, "rate_mash"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_mapa")}
|
|
||||||
key={`${index}rate_mapa`}
|
|
||||||
name={[field.name, "rate_mapa"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_ma2s")}
|
|
||||||
key={`${index}rate_ma2s`}
|
|
||||||
name={[field.name, "rate_ma2s"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_ma3s")}
|
|
||||||
key={`${index}rate_ma3s`}
|
|
||||||
name={[field.name, "rate_ma3s"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
{
|
|
||||||
// <Form.Item
|
|
||||||
// label={t("jobs.fields.rate_mabl")}
|
|
||||||
// key={`${index}rate_mabl`}
|
|
||||||
// name={[field.name, "rate_mabl"]}
|
|
||||||
// rules={[
|
|
||||||
// {
|
|
||||||
// required: true,
|
|
||||||
// //message: t("general.validation.required"),
|
|
||||||
// },
|
|
||||||
// ]}
|
|
||||||
// >
|
|
||||||
// <CurrencyInput min={0} />
|
|
||||||
// </Form.Item>
|
|
||||||
// <Form.Item
|
|
||||||
// label={t("jobs.fields.rate_macs")}
|
|
||||||
// key={`${index}rate_macs`}
|
|
||||||
// name={[field.name, "rate_macs"]}
|
|
||||||
// rules={[
|
|
||||||
// {
|
|
||||||
// required: true,
|
|
||||||
// //message: t("general.validation.required"),
|
|
||||||
// },
|
|
||||||
// ]}
|
|
||||||
// >
|
|
||||||
// <CurrencyInput min={0} />
|
|
||||||
// </Form.Item>
|
|
||||||
}
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_matd")}
|
|
||||||
key={`${index}rate_matd`}
|
|
||||||
name={[field.name, "rate_matd"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("jobs.fields.rate_mahw")}
|
|
||||||
key={`${index}rate_mahw`}
|
|
||||||
name={[field.name, "rate_mahw"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("bodyshop.actions.newlaborrate")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</LayoutFormRow>
|
||||||
}}
|
);
|
||||||
</Form.List>
|
}}
|
||||||
</LayoutFormRow>
|
</Form.List>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Form, Typography } from "antd";
|
import { Form, Typography } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
|
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
|
||||||
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -11,43 +12,45 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
|
|||||||
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
|
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow header={t("bodyshop.labels.notification_options")}>
|
||||||
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
|
<div>
|
||||||
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
|
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
|
||||||
{employeeOptions.length > 0 ? (
|
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
|
||||||
<Form.Item
|
{employeeOptions.length > 0 ? (
|
||||||
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
|
<Form.Item
|
||||||
name="notification_followers"
|
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
|
||||||
rules={[
|
name="notification_followers"
|
||||||
{
|
rules={[
|
||||||
type: "array",
|
{
|
||||||
message: t("general.validation.array")
|
type: "array",
|
||||||
},
|
message: t("general.validation.array")
|
||||||
{
|
},
|
||||||
validator: async (_, value) => {
|
{
|
||||||
if (!value || value.length === 0) {
|
validator: async (_, value) => {
|
||||||
return Promise.resolve(); // Allow empty array
|
if (!value || value.length === 0) {
|
||||||
|
return Promise.resolve(); // Allow empty array
|
||||||
|
}
|
||||||
|
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
|
||||||
|
if (hasInvalid) {
|
||||||
|
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
|
|
||||||
if (hasInvalid) {
|
|
||||||
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
}
|
]}
|
||||||
]}
|
>
|
||||||
>
|
<EmployeeSearchSelectComponent
|
||||||
<EmployeeSearchSelectComponent
|
style={{ minWidth: "100%" }}
|
||||||
style={{ minWidth: "100%" }}
|
mode="multiple"
|
||||||
mode="multiple"
|
options={employeeOptions}
|
||||||
options={employeeOptions}
|
placeholder={t("bodyshop.fields.notifications.placeholder")}
|
||||||
placeholder={t("bodyshop.fields.notifications.placeholder")}
|
showEmail={true}
|
||||||
showEmail={true}
|
/>
|
||||||
/>
|
</Form.Item>
|
||||||
</Form.Item>
|
) : (
|
||||||
) : (
|
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
|
||||||
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,19 @@ import { Button, Col, Form, Input, Row, Select, Space, Switch } from "antd";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
INLINE_TITLE_SEPARATOR_STYLE,
|
||||||
|
INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
|
||||||
const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"];
|
const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"];
|
||||||
@@ -68,195 +80,223 @@ export default function ShopInfoPartsScan({ form }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
|
<Form.List name={["md_parts_scan"]}>
|
||||||
<Form.List name={["md_parts_scan"]}>
|
{(fields, { add, remove, move }) => (
|
||||||
{(fields, { add, remove, move }) => (
|
<LayoutFormRow
|
||||||
|
header={t("bodyshop.labels.md_parts_scan")}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-parts-scan-rule"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() =>
|
||||||
|
add({
|
||||||
|
field: "line_desc",
|
||||||
|
operation: "contains",
|
||||||
|
mark_critical: true,
|
||||||
|
caseInsensitive: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.addpartsrule")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => {
|
{fields.length === 0 ? (
|
||||||
const selectedField = watchedFields?.[index]?.field || "line_desc";
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addpartsrule")} />
|
||||||
const fieldType = getFieldType(selectedField);
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
const selectedField = watchedFields?.[index]?.field || "line_desc";
|
||||||
|
const fieldType = getFieldType(selectedField);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item key={field.key}>
|
<Form.Item noStyle key={field.key}>
|
||||||
<Row gutter={[16, 16]} align="middle">
|
<InlineValidatedFormRow
|
||||||
{/* Select Field */}
|
form={form}
|
||||||
<Col span={6}>
|
errorNames={[["md_parts_scan", field.name, "field"]]}
|
||||||
<Form.Item
|
noDivider
|
||||||
label={t("bodyshop.fields.md_parts_scan.field")}
|
title={
|
||||||
name={[field.name, "field"]}
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
rules={[
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
{
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
required: true,
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.md_parts_scan.field")}</div>
|
||||||
message: t("general.validation.required", {
|
<Form.Item
|
||||||
label: t("bodyshop.fields.md_parts_scan.field")
|
noStyle
|
||||||
})
|
name={[field.name, "field"]}
|
||||||
}
|
rules={[
|
||||||
]}
|
{
|
||||||
>
|
required: true,
|
||||||
<Select
|
message: t("general.validation.required", {
|
||||||
options={fieldSelectOptions}
|
label: t("bodyshop.fields.md_parts_scan.field")
|
||||||
onChange={() => {
|
})
|
||||||
form.setFields([
|
}
|
||||||
{ name: ["md_parts_scan", index, "operation"], value: "contains" },
|
]}
|
||||||
{ name: ["md_parts_scan", index, "value"], value: undefined }
|
>
|
||||||
]);
|
<Select
|
||||||
}}
|
options={fieldSelectOptions}
|
||||||
/>
|
onChange={() => {
|
||||||
</Form.Item>
|
form.setFields([
|
||||||
</Col>
|
{ name: ["md_parts_scan", index, "operation"], value: "contains" },
|
||||||
|
{ name: ["md_parts_scan", index, "value"], value: undefined }
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
selector: INLINE_TITLE_INPUT_STYLE
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
{fieldType === "string" && (
|
||||||
|
<>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>
|
||||||
|
{t("bodyshop.fields.md_parts_scan.caseInsensitive")}
|
||||||
|
</div>
|
||||||
|
<Form.Item noStyle name={[field.name, "caseInsensitive"]} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>
|
||||||
|
{t("bodyshop.fields.md_parts_scan.mark_critical")}
|
||||||
|
</div>
|
||||||
|
<Form.Item noStyle name={[field.name, "mark_critical"]} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} align="middle">
|
||||||
|
{/* Operation */}
|
||||||
|
{fieldType !== "predefined" && fieldType && (
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.md_parts_scan.operation")}
|
||||||
|
name={[field.name, "operation"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: t("general.validation.required", {
|
||||||
|
label: t("bodyshop.fields.md_parts_scan.operation")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select options={operationOptions[fieldType]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Operation */}
|
{/* Value */}
|
||||||
{fieldType !== "predefined" && fieldType && (
|
{fieldType && (
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.md_parts_scan.operation")}
|
label={t("bodyshop.fields.md_parts_scan.value")}
|
||||||
name={[field.name, "operation"]}
|
name={[field.name, "value"]}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t("general.validation.required", {
|
message: t("general.validation.required", {
|
||||||
label: t("bodyshop.fields.md_parts_scan.operation")
|
label: t("bodyshop.fields.md_parts_scan.value")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Select options={operationOptions[fieldType]} />
|
{fieldType === "predefined" ? (
|
||||||
</Form.Item>
|
<Select
|
||||||
</Col>
|
options={
|
||||||
)}
|
selectedField === "part_type"
|
||||||
|
? predefinedPartTypes.map((type) => ({
|
||||||
|
label: type,
|
||||||
|
value: type
|
||||||
|
}))
|
||||||
|
: predefinedModLbrTypes.map((type) => ({
|
||||||
|
label: type,
|
||||||
|
value: type
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Value */}
|
{/* Update Field */}
|
||||||
{fieldType && (
|
<Col span={4}>
|
||||||
<Col span={6}>
|
<Form.Item
|
||||||
<Form.Item
|
label={t("bodyshop.fields.md_parts_scan.update_field")}
|
||||||
label={t("bodyshop.fields.md_parts_scan.value")}
|
name={[field.name, "update_field"]}
|
||||||
name={[field.name, "value"]}
|
>
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t("general.validation.required", {
|
|
||||||
label: t("bodyshop.fields.md_parts_scan.value")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{fieldType === "predefined" ? (
|
|
||||||
<Select
|
<Select
|
||||||
options={
|
options={fieldSelectOptions}
|
||||||
selectedField === "part_type"
|
allowClear
|
||||||
? predefinedPartTypes.map((type) => ({
|
onClear={() =>
|
||||||
label: type,
|
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
|
||||||
value: type
|
|
||||||
}))
|
|
||||||
: predefinedModLbrTypes.map((type) => ({
|
|
||||||
label: type,
|
|
||||||
value: type
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Update Field */}
|
||||||
|
<Col span={4}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.md_parts_scan.update_value")}
|
||||||
|
name={[field.name, "update_value"]}
|
||||||
|
dependencies={[["md_parts_scan", index, "update_field"]]}
|
||||||
|
tooltip={t("bodyshop.tooltips.md_parts_scan.update_value_tooltip")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: form.getFieldValue(["md_parts_scan", index, "update_field"]),
|
||||||
|
message: t("general.validation.required", {
|
||||||
|
label: t("bodyshop.fields.md_parts_scan.update_value")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Input />
|
<Input />
|
||||||
)}
|
</Form.Item>
|
||||||
</Form.Item>
|
</Col>
|
||||||
</Col>
|
</Row>
|
||||||
)}
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
{/* Case Sensitivity */}
|
);
|
||||||
{fieldType === "string" && (
|
})
|
||||||
<Col span={4}>
|
)}
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_parts_scan.caseInsensitive")}
|
|
||||||
name={[field.name, "caseInsensitive"]}
|
|
||||||
valuePropName="checked"
|
|
||||||
labelCol={{ span: 14 }}
|
|
||||||
wrapperCol={{ span: 10 }}
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mark Line as Critical */}
|
|
||||||
<Col span={4}>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_parts_scan.mark_critical")}
|
|
||||||
name={[field.name, "mark_critical"]}
|
|
||||||
valuePropName="checked"
|
|
||||||
labelCol={{ span: 14 }}
|
|
||||||
wrapperCol={{ span: 10 }}
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* Update Field */}
|
|
||||||
<Col span={4}>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_parts_scan.update_field")}
|
|
||||||
name={[field.name, "update_field"]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={fieldSelectOptions}
|
|
||||||
allowClear
|
|
||||||
onClear={() =>
|
|
||||||
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* Update Field */}
|
|
||||||
<Col span={4}>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_parts_scan.update_value")}
|
|
||||||
name={[field.name, "update_value"]}
|
|
||||||
dependencies={[["md_parts_scan", index, "update_field"]]}
|
|
||||||
tooltip={t("bodyshop.tooltips.md_parts_scan.update_value_tooltip")}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: form.getFieldValue(["md_parts_scan", index, "update_field"]),
|
|
||||||
message: t("general.validation.required", {
|
|
||||||
label: t("bodyshop.fields.md_parts_scan.update_value")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Col span={2}>
|
|
||||||
<Space>
|
|
||||||
<DeleteFilled onClick={() => remove(field.name)} />
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() =>
|
|
||||||
add({
|
|
||||||
field: "line_desc",
|
|
||||||
operation: "contains",
|
|
||||||
mark_critical: true,
|
|
||||||
caseInsensitive: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("bodyshop.actions.addpartsrule")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</LayoutFormRow>
|
||||||
</Form.List>
|
)}
|
||||||
</LayoutFormRow>
|
</Form.List>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ShopInfoRbacComponent({ bodyshop }) {
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<RbacWrapper action="shop:rbac">
|
<RbacWrapper action="shop:rbac">
|
||||||
<LayoutFormRow>
|
<LayoutFormRow header={t("bodyshop.labels.rbac_options")}>
|
||||||
{[
|
{[
|
||||||
...(HasFeatureAccess({ featureName: "export", bodyshop })
|
...(HasFeatureAccess({ featureName: "export", bodyshop })
|
||||||
? [
|
? [
|
||||||
@@ -435,6 +435,19 @@ export function ShopInfoRbacComponent({ bodyshop }) {
|
|||||||
>
|
>
|
||||||
<InputNumber />
|
<InputNumber />
|
||||||
</Form.Item>,
|
</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
|
<Form.Item
|
||||||
key="jobs:partsqueue"
|
key="jobs:partsqueue"
|
||||||
label={t("bodyshop.fields.rbac.jobs.partsqueue")}
|
label={t("bodyshop.fields.rbac.jobs.partsqueue")}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { Collapse, Divider, Form, Input, InputNumber, Space, Switch } from "antd";
|
import { Col, Collapse, Form, Input, InputNumber, Row, Switch } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -6,6 +6,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||||
|
import "./shop-info.responsibilitycenters.taxes.styles.scss";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
//currentUser: selectCurrentUser
|
//currentUser: selectCurrentUser
|
||||||
@@ -16,53 +17,102 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibilityCenters);
|
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibilityCenters);
|
||||||
|
|
||||||
|
const taxRootColProps = {
|
||||||
|
xs: 24,
|
||||||
|
sm: 12,
|
||||||
|
md: 8,
|
||||||
|
lg: { flex: "0 0 280px" },
|
||||||
|
xl: { flex: "0 0 240px" },
|
||||||
|
xxl: { flex: "0 0 300px" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const taxTierFieldColProps = {
|
||||||
|
xs: 24,
|
||||||
|
sm: 12,
|
||||||
|
lg: 6
|
||||||
|
};
|
||||||
|
|
||||||
export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
//Iteratively build the form items.
|
const profileTaxCards = [];
|
||||||
const formItems = [];
|
for (let typeNum = 1; typeNum <= 5; typeNum++) {
|
||||||
for (let tyCounter = 1; tyCounter <= 5; tyCounter++) {
|
const rootTaxItems = getRootTaxFormItems({ typeNum, bodyshop, t });
|
||||||
const section = [];
|
|
||||||
|
|
||||||
section.push(
|
profileTaxCards.push(
|
||||||
TaxFormItems({
|
<LayoutFormRow key={`profile-tax-type-${typeNum}`} header={t("bodyshop.labels.responsibilitycenters.tax_type_card", { typeNum })}>
|
||||||
typeNum: tyCounter,
|
<div style={{ display: "grid", rowGap: 12 }}>
|
||||||
rootElements: true,
|
<Row gutter={[16, 16]} wrap>
|
||||||
bodyshop
|
{rootTaxItems.map((item, index) => (
|
||||||
})
|
<Col key={item.key ?? `tax-root-${typeNum}-${index}`} {...taxRootColProps}>
|
||||||
|
{item}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[12, 12]} wrap className="responsibility-centers-tax-tier-grid">
|
||||||
|
{Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const typeNumIterator = index + 1;
|
||||||
|
const tierTaxItems = getTierTaxFormItems({
|
||||||
|
typeNum,
|
||||||
|
typeNumIterator,
|
||||||
|
t
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
key={`tax-tier-row-${typeNum}-${typeNumIterator}`}
|
||||||
|
xs={24}
|
||||||
|
className="responsibility-centers-tax-tier-grid__col"
|
||||||
|
>
|
||||||
|
<LayoutFormRow
|
||||||
|
header={t("bodyshop.labels.responsibilitycenters.tax_tier_card", { typeNumIterator })}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
styles={{
|
||||||
|
header: {
|
||||||
|
paddingInline: 12
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 12
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 8]} wrap>
|
||||||
|
{tierTaxItems.map((item, tierIndex) => (
|
||||||
|
<Col key={item.key ?? `tax-tier-${typeNum}-${typeNumIterator}-${tierIndex}`} {...taxTierFieldColProps}>
|
||||||
|
{item}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</LayoutFormRow>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
for (let iterator = 1; iterator <= 5; iterator++) {
|
|
||||||
section.push(
|
|
||||||
TaxFormItems({
|
|
||||||
typeNum: tyCounter,
|
|
||||||
typeNumIterator: iterator,
|
|
||||||
rootElements: false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
formItems.push(<Space wrap>{section}</Space>);
|
|
||||||
formItems.push(<Divider />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider titlePlacement="left" orientation="horizontal" style={{ marginTop: ".8rem" }}>
|
<LayoutFormRow header={t("jobs.labels.cieca_pft")}>
|
||||||
{t("jobs.labels.cieca_pft")}
|
<div>{profileTaxCards}</div>
|
||||||
</Divider>
|
</LayoutFormRow>
|
||||||
{formItems}
|
|
||||||
|
|
||||||
<Collapse
|
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.default_tax_setup")}>
|
||||||
items={[
|
<Collapse
|
||||||
{
|
items={[
|
||||||
key: "cieca_pfl",
|
{
|
||||||
label: t("jobs.labels.cieca_pfl"),
|
key: "cieca_pfl",
|
||||||
forceRender: true,
|
label: t("jobs.labels.cieca_pfl"),
|
||||||
children: (
|
forceRender: true,
|
||||||
<>
|
children: (
|
||||||
|
<>
|
||||||
<LayoutFormRow header={t("joblines.fields.lbr_types.LAB")}>
|
<LayoutFormRow header={t("joblines.fields.lbr_types.LAB")}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAB", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAB", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -89,7 +139,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -135,7 +185,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAD", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAD", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -162,7 +212,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -208,7 +258,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAE", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAE", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -235,7 +285,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -281,7 +331,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAF", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAF", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -308,7 +358,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -354,7 +404,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAG", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAG", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -381,7 +431,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -427,7 +477,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAM", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAM", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -454,7 +504,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -500,7 +550,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAR", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAR", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -527,7 +577,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -573,7 +623,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfl", "LAS", "lbr_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfl", "LAS", "lbr_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
|
||||||
@@ -673,7 +723,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={2} />
|
<InputNumber min={0} max={100} precision={2} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -740,7 +790,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.materials.mat_adjp")}
|
label={t("jobs.fields.materials.mat_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfm", "MAPA", "mat_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfm", "MAPA", "mat_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.materials.tax_ind")}
|
label={t("jobs.fields.materials.tax_ind")}
|
||||||
@@ -767,7 +817,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -825,7 +875,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
label={t("jobs.fields.materials.mat_adjp")}
|
label={t("jobs.fields.materials.mat_adjp")}
|
||||||
name={["md_responsibility_centers", "cieca_pfm", "MASH", "mat_adjp"]}
|
name={["md_responsibility_centers", "cieca_pfm", "MASH", "mat_adjp"]}
|
||||||
>
|
>
|
||||||
<InputNumber min={-100} max={100} precision={4} />
|
<InputNumber min={-100} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.materials.tax_ind")}
|
label={t("jobs.fields.materials.tax_ind")}
|
||||||
@@ -852,7 +902,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -893,15 +943,15 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cieca_pfo",
|
key: "cieca_pfo",
|
||||||
label: t("jobs.labels.cieca_pfo"),
|
label: t("jobs.labels.cieca_pfo"),
|
||||||
forceRender: true,
|
forceRender: true,
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LayoutFormRow noDivider>
|
<LayoutFormRow noDivider>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
|
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
|
||||||
@@ -2145,76 +2195,74 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
|
|||||||
<InputNumber min={0} max={100} precision={4} />
|
<InputNumber min={0} max={100} precision={4} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</LayoutFormRow>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
|
function getRootTaxFormItems({ typeNum, bodyshop, t }) {
|
||||||
const { t } = useTranslation();
|
return [
|
||||||
|
<Form.Item
|
||||||
if (rootElements)
|
key={`tax_type_${typeNum}_type`}
|
||||||
return (
|
label={t("bodyshop.fields.responsibilitycenter_tax_type", { typeNum })}
|
||||||
<>
|
rules={[
|
||||||
<Form.Item
|
{
|
||||||
label={t("bodyshop.fields.responsibilitycenter_tax_type", {
|
required: true
|
||||||
typeNum,
|
//message: t("general.validation.required"),
|
||||||
typeNumIterator
|
}
|
||||||
})}
|
]}
|
||||||
rules={[
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
|
||||||
{
|
>
|
||||||
required: true
|
<Input />
|
||||||
//message: t("general.validation.required"),
|
</Form.Item>,
|
||||||
}
|
<Form.Item
|
||||||
]}
|
key={`tax_type_${typeNum}_name`}
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
|
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
|
||||||
>
|
rules={[
|
||||||
<Input />
|
{
|
||||||
</Form.Item>
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
<Form.Item
|
}
|
||||||
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
|
]}
|
||||||
rules={[
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
|
||||||
{
|
>
|
||||||
required: true
|
<Input />
|
||||||
//message: t("general.validation.required"),
|
</Form.Item>,
|
||||||
}
|
<Form.Item
|
||||||
]}
|
key={`tax_type_${typeNum}_accountdesc`}
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
|
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
|
||||||
>
|
rules={[
|
||||||
<Input />
|
{
|
||||||
</Form.Item>
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
<Form.Item
|
}
|
||||||
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
|
]}
|
||||||
rules={[
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
|
||||||
{
|
>
|
||||||
required: true
|
<Input />
|
||||||
//message: t("general.validation.required"),
|
</Form.Item>,
|
||||||
}
|
<Form.Item
|
||||||
]}
|
key={`tax_type_${typeNum}_accountitem`}
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
|
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
|
||||||
>
|
rules={[
|
||||||
<Input />
|
{
|
||||||
</Form.Item>
|
required: true
|
||||||
<Form.Item
|
//message: t("general.validation.required"),
|
||||||
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
|
}
|
||||||
rules={[
|
]}
|
||||||
{
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]}
|
||||||
required: true
|
>
|
||||||
//message: t("general.validation.required"),
|
<Input />
|
||||||
}
|
</Form.Item>,
|
||||||
]}
|
...(bodyshopHasDmsKey(bodyshop)
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]}
|
? [
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
{bodyshopHasDmsKey(bodyshop) && (
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
key={`tax_type_${typeNum}_dms_acctnumber`}
|
||||||
label={t("bodyshop.fields.dms.dms_acctnumber")}
|
label={t("bodyshop.fields.dms.dms_acctnumber")}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
@@ -2226,71 +2274,64 @@ function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
]
|
||||||
</>
|
: [])
|
||||||
);
|
];
|
||||||
return (
|
}
|
||||||
<>
|
|
||||||
<Form.Item
|
function getTierTaxFormItems({ typeNum, typeNumIterator, t }) {
|
||||||
label={t("bodyshop.fields.responsibilitycenter_tax_tier", {
|
return [
|
||||||
typeNum,
|
<Form.Item
|
||||||
typeNumIterator
|
key={`tax_type_${typeNum}_tier_${typeNumIterator}`}
|
||||||
})}
|
label={t("bodyshop.labels.responsibilitycenters.tax_tier_short")}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true
|
required: true
|
||||||
//message: t("general.validation.required"),
|
//message: t("general.validation.required"),
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]}
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]}
|
||||||
>
|
>
|
||||||
<InputNumber precision={0} min={0} />
|
<InputNumber precision={0} min={0} />
|
||||||
</Form.Item>
|
</Form.Item>,
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.responsibilitycenter_tax_thres", {
|
key={`tax_type_${typeNum}_threshold_${typeNumIterator}`}
|
||||||
typeNum,
|
label={t("bodyshop.labels.responsibilitycenters.tax_threshold_short")}
|
||||||
typeNumIterator
|
rules={[
|
||||||
})}
|
{
|
||||||
rules={[
|
required: true
|
||||||
{
|
//message: t("general.validation.required"),
|
||||||
required: true
|
}
|
||||||
//message: t("general.validation.required"),
|
]}
|
||||||
}
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
|
||||||
]}
|
>
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
|
<InputNumber min={0} precision={2} />
|
||||||
>
|
</Form.Item>,
|
||||||
<InputNumber min={0} precision={2} />
|
<Form.Item
|
||||||
</Form.Item>
|
key={`tax_type_${typeNum}_rate_${typeNumIterator}`}
|
||||||
<Form.Item
|
label={t("bodyshop.labels.responsibilitycenters.tax_rate_short")}
|
||||||
label={t("bodyshop.fields.responsibilitycenter_tax_rate", {
|
rules={[
|
||||||
typeNum,
|
{
|
||||||
typeNumIterator
|
required: true
|
||||||
})}
|
//message: t("general.validation.required"),
|
||||||
rules={[
|
}
|
||||||
{
|
]}
|
||||||
required: true
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
|
||||||
//message: t("general.validation.required"),
|
>
|
||||||
}
|
<InputNumber min={0} precision={2} suffix="%" />
|
||||||
]}
|
</Form.Item>,
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
|
<Form.Item
|
||||||
>
|
key={`tax_type_${typeNum}_surcharge_${typeNumIterator}`}
|
||||||
<InputNumber min={0} precision={2} />
|
label={t("bodyshop.labels.responsibilitycenters.tax_surcharge_short")}
|
||||||
</Form.Item>
|
rules={[
|
||||||
<Form.Item
|
{
|
||||||
label={t("bodyshop.fields.responsibilitycenter_tax_sur", {
|
required: true
|
||||||
typeNum,
|
//message: t("general.validation.required"),
|
||||||
typeNumIterator
|
}
|
||||||
})}
|
]}
|
||||||
rules={[
|
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
|
||||||
{
|
>
|
||||||
required: true
|
<InputNumber min={0} precision={2} suffix="%" />
|
||||||
//message: t("general.validation.required"),
|
</Form.Item>
|
||||||
}
|
];
|
||||||
]}
|
|
||||||
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} precision={2} />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.responsibility-centers-tax-tier-grid__col.ant-col {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.responsibility-centers-tax-tier-grid__col.ant-col {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.responsibility-centers-tax-tier-grid__col.ant-col {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2400px) {
|
||||||
|
.responsibility-centers-tax-tier-grid__col.ant-col {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
max-width: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function ShopInfoRoGuard({ form }) {
|
|||||||
{() => {
|
{() => {
|
||||||
const disabled = !form.getFieldValue(["md_ro_guard", "enabled"]);
|
const disabled = !form.getFieldValue(["md_ro_guard", "enabled"]);
|
||||||
return (
|
return (
|
||||||
<LayoutFormRow noDivider>
|
<LayoutFormRow header={t("bodyshop.labels.md_ro_guard_options")}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.md_ro_guard.totalgppercent_minimum")}
|
label={t("bodyshop.fields.md_ro_guard.totalgppercent_minimum")}
|
||||||
name={["md_ro_guard", "totalgppercent_minimum"]}
|
name={["md_ro_guard", "totalgppercent_minimum"]}
|
||||||
@@ -32,7 +32,7 @@ export default function ShopInfoRoGuard({ form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} precision={1} disabled={disabled} />
|
<InputNumber min={0} max={100} precision={1} suffix="%" disabled={disabled} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { CloseOutlined, DeleteFilled, HolderOutlined } from "@ant-design/icons";
|
||||||
|
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
|
import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Button, Form, Select, Space } from "antd";
|
import { Button, Form, Select, Space } from "antd";
|
||||||
import { useState } from "react";
|
|
||||||
import { ChromePicker } from "react-color";
|
import { ChromePicker } from "react-color";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./shop-info.color.utils";
|
||||||
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -24,10 +31,341 @@ const SelectorDiv = styled.div`
|
|||||||
.ant-form-item .ant-select {
|
.ant-form-item .ant-select {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.production-status-color-title-select {
|
||||||
|
min-width: 160px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.production-status-color-title-select .ant-select-selector {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding-inline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.production-status-color-title-select .ant-select-selection-item,
|
||||||
|
.production-status-color-title-select .ant-select-selection-placeholder {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-select .ant-select-selector {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-select .ant-select-selection-wrap {
|
||||||
|
gap: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline-end: 6px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .ant-select-selection-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 30px;
|
||||||
|
min-width: 132px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-inline: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--ant-color-border);
|
||||||
|
background: var(--ant-color-fill-quaternary);
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
margin-inline-end: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .job-statuses-source-tag-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
flex: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .ant-select-selection-item-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .ant-select-selection-item:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove:hover {
|
||||||
|
background: var(--ant-color-fill-secondary);
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-statuses-source-tag-wrapper--dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const normalizeStatuses = (statuses) => [...new Set((statuses || []).map((item) => item?.trim()).filter(Boolean))];
|
||||||
|
|
||||||
|
const getTranslatedDragRect = (active, delta) => {
|
||||||
|
const rect = active?.rect?.current?.initial || active?.rect?.current?.translated;
|
||||||
|
|
||||||
|
if (!rect) return null;
|
||||||
|
|
||||||
|
const x = delta?.x || 0;
|
||||||
|
const y = delta?.y || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: rect.left + x,
|
||||||
|
right: rect.right + x,
|
||||||
|
top: rect.top + y,
|
||||||
|
bottom: rect.bottom + y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPointWithinRect = (point, rect) => {
|
||||||
|
if (!point || !rect) return false;
|
||||||
|
|
||||||
|
return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DraggableStatusTag = ({ label, value, closable, onClose }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: value
|
||||||
|
});
|
||||||
|
const labelText = String(label ?? value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`job-statuses-source-tag-wrapper ${isDragging ? "job-statuses-source-tag-wrapper--dragging" : ""}`}
|
||||||
|
data-status-tag-value={value}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="ant-select-selection-item"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target.closest(".ant-select-selection-item-remove")) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target.closest(".ant-select-selection-item-remove")) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
title={labelText}
|
||||||
|
>
|
||||||
|
<span className="job-statuses-source-tag-handle" aria-hidden>
|
||||||
|
<HolderOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="ant-select-selection-item-content">{labelText}</span>
|
||||||
|
{closable ? (
|
||||||
|
<span
|
||||||
|
className="ant-select-selection-item-remove"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose?.(event);
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableStatusesSelect = ({ value, onChange, mode = "tags", options = [] }) => {
|
||||||
|
const statuses = normalizeStatuses(value);
|
||||||
|
const isTagsMode = mode === "tags";
|
||||||
|
const [knownStatuses, setKnownStatuses] = useState(statuses);
|
||||||
|
const selectWrapperRef = useRef(null);
|
||||||
|
const dragRectRef = useRef(null);
|
||||||
|
const tagSensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusesChange = (nextValues) => {
|
||||||
|
const normalizedNextValues = normalizeStatuses(nextValues);
|
||||||
|
if (isTagsMode) {
|
||||||
|
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...normalizedNextValues]));
|
||||||
|
}
|
||||||
|
onChange?.(normalizedNextValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTagsMode) {
|
||||||
|
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...statuses]));
|
||||||
|
}
|
||||||
|
}, [isTagsMode, statuses]);
|
||||||
|
|
||||||
|
const shouldMoveStatusToEnd = (activeId, dragRect) => {
|
||||||
|
const selectRect =
|
||||||
|
selectWrapperRef.current?.querySelector?.(".ant-select-selector")?.getBoundingClientRect?.() ||
|
||||||
|
selectWrapperRef.current?.getBoundingClientRect?.();
|
||||||
|
if (!dragRect || !selectRect) return false;
|
||||||
|
|
||||||
|
const dragLeadingPoint = {
|
||||||
|
x: dragRect.left,
|
||||||
|
y: dragRect.top
|
||||||
|
};
|
||||||
|
const dragTrailingPoint = {
|
||||||
|
x: dragRect.right,
|
||||||
|
y: dragRect.bottom
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isPointWithinRect(dragLeadingPoint, selectRect) && !isPointWithinRect(dragTrailingPoint, selectRect)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailingStatus = statuses.filter((status) => status !== activeId).at(-1);
|
||||||
|
if (!trailingStatus) return false;
|
||||||
|
|
||||||
|
const trailingTagNode = selectWrapperRef.current?.querySelector?.(
|
||||||
|
`.job-statuses-source-tag-wrapper[data-status-tag-value="${CSS.escape(String(trailingStatus))}"]`
|
||||||
|
);
|
||||||
|
const trailingTagRect = trailingTagNode?.getBoundingClientRect?.();
|
||||||
|
|
||||||
|
if (!trailingTagRect) return false;
|
||||||
|
|
||||||
|
const isOnTrailingRow = dragRect.bottom >= trailingTagRect.top && dragRect.top <= trailingTagRect.bottom;
|
||||||
|
if (isOnTrailingRow) {
|
||||||
|
return dragRect.left >= trailingTagRect.right - 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dragRect.top >= trailingTagRect.bottom - 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusSortEnd = ({ active, over, delta }) => {
|
||||||
|
const oldIndex = statuses.indexOf(active.id);
|
||||||
|
const dragRect = dragRectRef.current || getTranslatedDragRect(active, delta);
|
||||||
|
dragRectRef.current = null;
|
||||||
|
|
||||||
|
if (oldIndex < 0) return;
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
if (oldIndex !== statuses.length - 1 && shouldMoveStatusToEnd(active.id, dragRect)) {
|
||||||
|
onChange?.(arrayMove(statuses, oldIndex, statuses.length - 1));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.id === over.id) return;
|
||||||
|
|
||||||
|
const newIndex = statuses.indexOf(over.id);
|
||||||
|
|
||||||
|
if (newIndex < 0) return;
|
||||||
|
|
||||||
|
onChange?.(arrayMove(statuses, oldIndex, newIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatusTag = ({ label, value: tagValue, closable, onClose }) => {
|
||||||
|
return <DraggableStatusTag closable={closable} label={label} onClose={onClose} value={tagValue} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusSelectOptions = isTagsMode
|
||||||
|
? knownStatuses.map((status) => ({
|
||||||
|
value: status,
|
||||||
|
label: status
|
||||||
|
}))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
className="job-statuses-source-select"
|
||||||
|
mode={mode}
|
||||||
|
onChange={handleStatusesChange}
|
||||||
|
options={statusSelectOptions}
|
||||||
|
value={statuses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={selectWrapperRef}>
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragCancel={() => {
|
||||||
|
dragRectRef.current = null;
|
||||||
|
}}
|
||||||
|
onDragEnd={handleStatusSortEnd}
|
||||||
|
onDragMove={({ active, delta }) => {
|
||||||
|
dragRectRef.current = getTranslatedDragRect(active, delta);
|
||||||
|
}}
|
||||||
|
sensors={tagSensors}
|
||||||
|
>
|
||||||
|
<SortableContext items={statuses} strategy={rectSortingStrategy}>
|
||||||
|
<Select
|
||||||
|
className="job-statuses-source-select"
|
||||||
|
mode={mode}
|
||||||
|
onChange={handleStatusesChange}
|
||||||
|
options={statusSelectOptions}
|
||||||
|
tagRender={renderStatusTag}
|
||||||
|
value={statuses}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const allStatuses = normalizeStatuses(Form.useWatch(["md_ro_statuses", "statuses"], form));
|
||||||
|
const productionStatuses = Form.useWatch(["md_ro_statuses", "production_statuses"], form) || [];
|
||||||
|
const additionalBoardStatuses = Form.useWatch(["md_ro_statuses", "additional_board_statuses"], form) || [];
|
||||||
|
const productionColors = Form.useWatch(["md_ro_statuses", "production_colors"], form) || [];
|
||||||
|
const statusOptions = allStatuses;
|
||||||
|
const statusSelectOptions = statusOptions.map((item) => ({ value: item, label: item }));
|
||||||
|
const availableProductionStatuses = [...new Set([...productionStatuses, ...additionalBoardStatuses].filter(Boolean))];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
treatments: { Production_List_Status_Colors }
|
treatments: { Production_List_Status_Colors }
|
||||||
@@ -37,117 +375,119 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop.imexshopid
|
||||||
});
|
});
|
||||||
|
|
||||||
const [options, setOptions] = useState(form.getFieldValue(["md_ro_statuses", "statuses"]) || []);
|
|
||||||
|
|
||||||
const [productionStatus, setProductionStatus] = useState(
|
|
||||||
(form.getFieldValue(["md_ro_statuses", "production_statuses"]) || []).concat(
|
|
||||||
form.getFieldValue(["md_ro_statuses", "additional_board_statuses"]) || []
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
setOptions(form.getFieldValue(["md_ro_statuses", "statuses"]));
|
|
||||||
setProductionStatus(
|
|
||||||
form
|
|
||||||
.getFieldValue(["md_ro_statuses", "production_statuses"])
|
|
||||||
.concat(form.getFieldValue(["md_ro_statuses", "additional_board_statuses"]))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectorDiv id="jobstatus">
|
<SelectorDiv id="jobstatus">
|
||||||
<Form.Item
|
<LayoutFormRow grow header={t("bodyshop.labels.job_status_options")}>
|
||||||
name={["md_ro_statuses", "statuses"]}
|
<div>
|
||||||
label={t("bodyshop.labels.alljobstatuses")}
|
<Form.Item
|
||||||
rules={[
|
name={["md_ro_statuses", "statuses"]}
|
||||||
{
|
label={t("bodyshop.labels.alljobstatuses")}
|
||||||
required: true,
|
required
|
||||||
//message: t("general.validation.required"),
|
rules={[
|
||||||
type: "array"
|
{
|
||||||
}
|
validator: async (_, value) => {
|
||||||
]}
|
const populatedStatuses = normalizeStatuses(value);
|
||||||
>
|
|
||||||
<Select mode="tags" onBlur={handleBlur} />
|
if (populatedStatuses.length === 0) {
|
||||||
</Form.Item>
|
return Promise.reject(
|
||||||
<Form.Item
|
new Error(
|
||||||
name={["md_ro_statuses", "active_statuses"]}
|
t("general.validation.required", {
|
||||||
label={t("bodyshop.fields.statuses.active_statuses")}
|
label: t("bodyshop.labels.alljobstatuses")
|
||||||
rules={[
|
})
|
||||||
{
|
)
|
||||||
required: true,
|
);
|
||||||
//message: t("general.validation.required"),
|
}
|
||||||
type: "array"
|
|
||||||
}
|
if (populatedStatuses.length !== (value || []).filter(Boolean).length) {
|
||||||
]}
|
return Promise.reject(new Error(t("bodyshop.errors.duplicate_job_status")));
|
||||||
>
|
}
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
}
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["md_ro_statuses", "pre_production_statuses"]}
|
>
|
||||||
label={t("bodyshop.fields.statuses.pre_production_statuses")}
|
<SortableStatusesSelect />
|
||||||
rules={[
|
</Form.Item>
|
||||||
{
|
<Form.Item
|
||||||
required: true,
|
name={["md_ro_statuses", "active_statuses"]}
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.statuses.active_statuses")}
|
||||||
type: "array"
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true,
|
||||||
>
|
//message: t("general.validation.required"),
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
type: "array"
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["md_ro_statuses", "production_statuses"]}
|
>
|
||||||
label={t("bodyshop.fields.statuses.production_statuses")}
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
rules={[
|
</Form.Item>
|
||||||
{
|
<Form.Item
|
||||||
required: true,
|
name={["md_ro_statuses", "pre_production_statuses"]}
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.statuses.pre_production_statuses")}
|
||||||
type: "array"
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true,
|
||||||
>
|
//message: t("general.validation.required"),
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
type: "array"
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["md_ro_statuses", "post_production_statuses"]}
|
>
|
||||||
label={t("bodyshop.fields.statuses.post_production_statuses")}
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
rules={[
|
</Form.Item>
|
||||||
{
|
<Form.Item
|
||||||
required: true,
|
name={["md_ro_statuses", "production_statuses"]}
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.statuses.production_statuses")}
|
||||||
type: "array"
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true,
|
||||||
>
|
//message: t("general.validation.required"),
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
type: "array"
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["md_ro_statuses", "ready_statuses"]}
|
>
|
||||||
label={t("bodyshop.fields.statuses.ready_statuses")}
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
rules={[
|
</Form.Item>
|
||||||
{
|
<Form.Item
|
||||||
//required: true,
|
name={["md_ro_statuses", "post_production_statuses"]}
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.statuses.post_production_statuses")}
|
||||||
type: "array"
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true,
|
||||||
>
|
//message: t("general.validation.required"),
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
type: "array"
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["md_ro_statuses", "additional_board_statuses"]}
|
>
|
||||||
label={t("bodyshop.fields.statuses.additional_board_statuses")}
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
rules={[
|
</Form.Item>
|
||||||
{
|
<Form.Item
|
||||||
//required: true,
|
name={["md_ro_statuses", "ready_statuses"]}
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.statuses.ready_statuses")}
|
||||||
type: "array"
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
//required: true,
|
||||||
>
|
//message: t("general.validation.required"),
|
||||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
type: "array"
|
||||||
</Form.Item>
|
}
|
||||||
<LayoutFormRow noDivider>
|
]}
|
||||||
|
>
|
||||||
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={["md_ro_statuses", "additional_board_statuses"]}
|
||||||
|
label={t("bodyshop.fields.statuses.additional_board_statuses")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
//required: true,
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
type: "array"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
|
<LayoutFormRow grow header={t("general.actions.defaults")}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_scheduled")}
|
label={t("bodyshop.fields.statuses.default_scheduled")}
|
||||||
rules={[
|
rules={[
|
||||||
@@ -158,7 +498,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_scheduled"]}
|
name={["md_ro_statuses", "default_scheduled"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_arrived")}
|
label={t("bodyshop.fields.statuses.default_arrived")}
|
||||||
@@ -170,7 +510,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_arrived"]}
|
name={["md_ro_statuses", "default_arrived"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_exported")}
|
label={t("bodyshop.fields.statuses.default_exported")}
|
||||||
@@ -182,7 +522,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_exported"]}
|
name={["md_ro_statuses", "default_exported"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_imported")}
|
label={t("bodyshop.fields.statuses.default_imported")}
|
||||||
@@ -194,7 +534,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_imported"]}
|
name={["md_ro_statuses", "default_imported"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_invoiced")}
|
label={t("bodyshop.fields.statuses.default_invoiced")}
|
||||||
@@ -206,7 +546,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_invoiced"]}
|
name={["md_ro_statuses", "default_invoiced"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_completed")}
|
label={t("bodyshop.fields.statuses.default_completed")}
|
||||||
@@ -218,7 +558,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_completed"]}
|
name={["md_ro_statuses", "default_completed"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_delivered")}
|
label={t("bodyshop.fields.statuses.default_delivered")}
|
||||||
@@ -230,7 +570,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_delivered"]}
|
name={["md_ro_statuses", "default_delivered"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.statuses.default_void")}
|
label={t("bodyshop.fields.statuses.default_void")}
|
||||||
@@ -242,73 +582,122 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
|||||||
]}
|
]}
|
||||||
name={["md_ro_statuses", "default_void"]}
|
name={["md_ro_statuses", "default_void"]}
|
||||||
>
|
>
|
||||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
<Select options={statusSelectOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
{Production_List_Status_Colors.treatment === "on" && (
|
{Production_List_Status_Colors.treatment === "on" && (
|
||||||
<LayoutFormRow grow header={t("bodyshop.fields.statuses.production_colors")} id="production_colors">
|
<Form.List name={["md_ro_statuses", "production_colors"]}>
|
||||||
<Form.List name={["md_ro_statuses", "production_colors"]}>
|
{(fields, { add, remove }) => {
|
||||||
{(fields, { add, remove }) => {
|
return (
|
||||||
return (
|
<LayoutFormRow
|
||||||
|
grow
|
||||||
|
header={t("bodyshop.fields.statuses.production_colors")}
|
||||||
|
id="production_colors"
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-production-status-color"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
add({
|
||||||
|
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.add_production_status_color")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Space size="large" wrap>
|
{fields.length === 0 ? (
|
||||||
{fields.map((field, index) => (
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_production_status_color")} />
|
||||||
<Form.Item key={field.key}>
|
) : (
|
||||||
<Space orientation="vertical">
|
<Space size="large" wrap align="start">
|
||||||
<div style={{ display: "flex" }}>
|
{fields.map((field, index) => {
|
||||||
<Form.Item
|
const productionColor = productionColors[field.name] || {};
|
||||||
style={{ flex: 1 }}
|
const productionColorSurfaceStyles = getTintedCardSurfaceStyles(productionColor.color);
|
||||||
label={t("jobs.fields.status")}
|
const selectedProductionColorStatuses = productionColors
|
||||||
key={`${index}status`}
|
.map((item) => item?.status)
|
||||||
name={[field.name, "status"]}
|
.filter(Boolean);
|
||||||
rules={[
|
const productionColorStatusOptions = [
|
||||||
{
|
...new Set([productionColor.status, ...availableProductionStatuses])
|
||||||
required: true
|
]
|
||||||
//message: t("general.validation.required"),
|
.filter(Boolean)
|
||||||
}
|
.filter(
|
||||||
]}
|
(status) =>
|
||||||
>
|
status === productionColor.status || !selectedProductionColorStatuses.includes(status)
|
||||||
<Select options={productionStatus.map((item) => ({ value: item, label: item }))} />
|
);
|
||||||
</Form.Item>
|
|
||||||
<DeleteFilled
|
return (
|
||||||
onClick={() => {
|
<InlineValidatedFormRow
|
||||||
remove(field.name);
|
form={form}
|
||||||
}}
|
errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]}
|
||||||
/>
|
key={field.key}
|
||||||
</div>
|
noDivider
|
||||||
<Form.Item
|
title={
|
||||||
label={t("bodyshop.fields.statuses.color")}
|
<Form.Item
|
||||||
key={`${index}color`}
|
noStyle
|
||||||
name={[field.name, "color"]}
|
key={`${index}status`}
|
||||||
rules={[
|
name={[field.name, "status"]}
|
||||||
{
|
rules={[
|
||||||
required: true
|
{
|
||||||
//message: t("general.validation.required"),
|
required: true
|
||||||
}
|
//message: t("general.validation.required"),
|
||||||
]}
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
className="production-status-color-title-select"
|
||||||
|
variant="borderless"
|
||||||
|
placeholder={getFormListItemTitle(
|
||||||
|
t("jobs.fields.status"),
|
||||||
|
index,
|
||||||
|
productionColor.status
|
||||||
|
)}
|
||||||
|
options={productionColorStatusOptions.map((item) => ({
|
||||||
|
value: item,
|
||||||
|
label: item
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...productionColorSurfaceStyles}
|
||||||
|
style={{ width: 260, marginBottom: 0 }}
|
||||||
>
|
>
|
||||||
<ColorPicker />
|
<div>
|
||||||
</Form.Item>
|
<Form.Item
|
||||||
</Space>
|
key={`${index}color`}
|
||||||
</Form.Item>
|
name={[field.name, "color"]}
|
||||||
))}
|
rules={[
|
||||||
</Space>
|
{
|
||||||
<Form.Item>
|
required: true
|
||||||
<Button
|
//message: t("general.validation.required"),
|
||||||
type="dashed"
|
}
|
||||||
onClick={() => {
|
]}
|
||||||
add();
|
>
|
||||||
}}
|
<ColorPicker />
|
||||||
style={{ width: "100%" }}
|
</Form.Item>
|
||||||
>
|
</div>
|
||||||
{t("general.actions.add")}
|
</InlineValidatedFormRow>
|
||||||
</Button>
|
);
|
||||||
</Form.Item>
|
})}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</LayoutFormRow>
|
||||||
}}
|
);
|
||||||
</Form.List>
|
}}
|
||||||
</LayoutFormRow>
|
</Form.List>
|
||||||
)}
|
)}
|
||||||
</SelectorDiv>
|
</SelectorDiv>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled, ReloadOutlined } from "@ant-design/icons";
|
||||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd";
|
import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch, TimePicker, Tooltip } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -7,8 +7,16 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||||
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
|
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import { ColorPicker } from "./shop-info.rostatus.component";
|
import { ColorPicker } from "./shop-info.rostatus.component";
|
||||||
|
import {
|
||||||
|
DEFAULT_TRANSLUCENT_CARD_COLOR,
|
||||||
|
DEFAULT_TRANSLUCENT_PICKER_COLOR,
|
||||||
|
getTintedCardSurfaceStyles
|
||||||
|
} from "./shop-info.color.utils";
|
||||||
|
import "./shop-info.scheduling.styles.scss";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -17,301 +25,514 @@ const mapDispatchToProps = () => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WORKING_DAYS = [
|
||||||
|
{ key: "sunday", labelKey: "general.labels.sunday" },
|
||||||
|
{ key: "monday", labelKey: "general.labels.monday" },
|
||||||
|
{ key: "tuesday", labelKey: "general.labels.tuesday" },
|
||||||
|
{ key: "wednesday", labelKey: "general.labels.wednesday" },
|
||||||
|
{ key: "thursday", labelKey: "general.labels.thursday" },
|
||||||
|
{ key: "friday", labelKey: "general.labels.friday" },
|
||||||
|
{ key: "saturday", labelKey: "general.labels.saturday" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const APPOINTMENT_COLOR_PICKER_STYLES = {
|
||||||
|
default: {
|
||||||
|
wrap: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "12px",
|
||||||
|
alignItems: "flex-start"
|
||||||
|
},
|
||||||
|
hue: {
|
||||||
|
flex: "1 1 180px",
|
||||||
|
height: "12px",
|
||||||
|
position: "relative",
|
||||||
|
marginTop: "20px"
|
||||||
|
},
|
||||||
|
swatches: {
|
||||||
|
flex: "1 1 160px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEDULING_BUCKET_COLOR_PICKER_STYLES = {
|
||||||
|
default: {
|
||||||
|
picker: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "color-mix(in srgb, var(--imex-form-surface) 92%, transparent)",
|
||||||
|
boxShadow: "none",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--imex-form-surface-border) 72%, transparent)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden"
|
||||||
|
},
|
||||||
|
saturation: {
|
||||||
|
width: "100%",
|
||||||
|
paddingBottom: "48%",
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: "8px 8px 0 0",
|
||||||
|
overflow: "hidden"
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: "12px"
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px"
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
width: "28px"
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
marginTop: "0",
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "999px"
|
||||||
|
},
|
||||||
|
toggles: {
|
||||||
|
flex: "1"
|
||||||
|
},
|
||||||
|
hue: {
|
||||||
|
height: "10px",
|
||||||
|
position: "relative",
|
||||||
|
marginBottom: "8px"
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
height: "10px",
|
||||||
|
position: "relative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TITLE_INPUT_STYLE = {
|
||||||
|
background: "color-mix(in srgb, var(--imex-form-surface) 78%, transparent)",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--imex-form-surface-border) 72%, transparent)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 500
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TITLE_INPUT_ROW_STYLE = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
minWidth: 180,
|
||||||
|
maxWidth: "100%"
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TITLE_INPUT_GROUP_STYLE = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TITLE_INPUT_LABEL_STYLE = {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
opacity: 0.75,
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
};
|
||||||
|
|
||||||
export function ShopInfoSchedulingComponent({ form, bodyshop }) {
|
export function ShopInfoSchedulingComponent({ form, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const appointmentColors = Form.useWatch(["appt_colors"], form) || form.getFieldValue(["appt_colors"]) || [];
|
||||||
|
const schedulingBuckets = Form.useWatch(["ssbuckets"], form) || form.getFieldValue(["ssbuckets"]) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<LayoutFormRow id="shopinfo-scheduling">
|
<LayoutFormRow grow header={t("bodyshop.labels.scheduling")} id="shopinfo-scheduling">
|
||||||
<Form.Item
|
<>
|
||||||
label={t("bodyshop.fields.appt_length")}
|
<Form.Item
|
||||||
name={"appt_length"}
|
name={["appt_alt_transport"]}
|
||||||
rules={[
|
label={t("bodyshop.fields.appt_alt_transport")}
|
||||||
{
|
rules={[
|
||||||
required: true
|
{
|
||||||
//message: t("general.validation.required"),
|
//message: t("general.validation.required"),
|
||||||
}
|
type: "array"
|
||||||
]}
|
}
|
||||||
>
|
]}
|
||||||
<InputNumber min={15} precision={0} />
|
>
|
||||||
</Form.Item>
|
<Select mode="tags" />
|
||||||
<Form.Item
|
</Form.Item>
|
||||||
label={t("bodyshop.fields.schedule_start_time")}
|
<Form.Item
|
||||||
name={"schedule_start_time"}
|
name={["md_lost_sale_reasons"]}
|
||||||
rules={[
|
label={t("bodyshop.fields.md_lost_sale_reasons")}
|
||||||
{
|
rules={[
|
||||||
required: true
|
{
|
||||||
//message: t("general.validation.required"),
|
// required: true,
|
||||||
}
|
//message: t("general.validation.required"),
|
||||||
]}
|
type: "array"
|
||||||
id="schedule_start_time"
|
}
|
||||||
>
|
]}
|
||||||
<TimePicker disableSeconds={true} format="HH:mm" />
|
>
|
||||||
</Form.Item>
|
<Select mode="tags" />
|
||||||
<Form.Item
|
</Form.Item>
|
||||||
label={t("bodyshop.fields.schedule_end_time")}
|
<Row gutter={[16, 0]} wrap>
|
||||||
name={"schedule_end_time"}
|
<Col xs={24} sm={12} xl={6}>
|
||||||
rules={[
|
<Form.Item
|
||||||
{
|
label={t("bodyshop.fields.appt_length")}
|
||||||
required: true
|
name={"appt_length"}
|
||||||
//message: t("general.validation.required"),
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true
|
||||||
id="schedule_end_time"
|
//message: t("general.validation.required"),
|
||||||
>
|
}
|
||||||
<TimePicker disableSeconds={true} format="HH:mm" />
|
]}
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item
|
<InputNumber min={15} precision={0} suffix="min" />
|
||||||
name={["appt_alt_transport"]}
|
</Form.Item>
|
||||||
label={t("bodyshop.fields.appt_alt_transport")}
|
</Col>
|
||||||
rules={[
|
<Col xs={24} sm={12} xl={6}>
|
||||||
{
|
<Form.Item
|
||||||
//message: t("general.validation.required"),
|
label={t("bodyshop.fields.schedule_start_time")}
|
||||||
type: "array"
|
name={"schedule_start_time"}
|
||||||
}
|
rules={[
|
||||||
]}
|
{
|
||||||
>
|
required: true
|
||||||
<Select mode="tags" />
|
//message: t("general.validation.required"),
|
||||||
</Form.Item>
|
}
|
||||||
<Form.Item
|
]}
|
||||||
name={["ss_configuration", "dailyhrslimit"]}
|
id="schedule_start_time"
|
||||||
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
|
>
|
||||||
>
|
<TimePicker disableSeconds={true} format="HH:mm" />
|
||||||
<InputNumber min={0} />
|
</Form.Item>
|
||||||
</Form.Item>
|
</Col>
|
||||||
<Form.Item
|
<Col xs={24} sm={12} xl={6}>
|
||||||
name={["ss_configuration", "nobusinessdays"]}
|
<Form.Item
|
||||||
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
|
label={t("bodyshop.fields.schedule_end_time")}
|
||||||
valuePropName="checked"
|
name={"schedule_end_time"}
|
||||||
>
|
rules={[
|
||||||
<Switch />
|
{
|
||||||
</Form.Item>
|
required: true
|
||||||
<Form.Item
|
//message: t("general.validation.required"),
|
||||||
name={["md_lost_sale_reasons"]}
|
}
|
||||||
label={t("bodyshop.fields.md_lost_sale_reasons")}
|
]}
|
||||||
rules={[
|
id="schedule_end_time"
|
||||||
{
|
>
|
||||||
// required: true,
|
<TimePicker disableSeconds={true} format="HH:mm" />
|
||||||
//message: t("general.validation.required"),
|
</Form.Item>
|
||||||
type: "array"
|
</Col>
|
||||||
}
|
<Col xs={24} sm={12} xl={6}>
|
||||||
]}
|
<Form.Item
|
||||||
>
|
name={["ss_configuration", "dailyhrslimit"]}
|
||||||
<Select mode="tags" />
|
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
|
||||||
</Form.Item>
|
>
|
||||||
|
<InputNumber min={0} suffix="hrs" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} xl={6}>
|
||||||
|
<Form.Item
|
||||||
|
name={["ss_configuration", "nobusinessdays"]}
|
||||||
|
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<Divider titlePlacement="left">{t("bodyshop.labels.workingdays")}</Divider>
|
<LayoutFormRow header={t("bodyshop.labels.workingdays")} id="workingdays">
|
||||||
<Space wrap size="large" id="workingdays">
|
<Space wrap size="middle">
|
||||||
<Form.Item label={t("general.labels.sunday")} name={["workingdays", "sunday"]} valuePropName="checked">
|
{WORKING_DAYS.map(({ key, labelKey }) => (
|
||||||
<Switch />
|
<Form.Item key={key} label={t(labelKey)} name={["workingdays", key]} valuePropName="checked">
|
||||||
</Form.Item>
|
<Switch />
|
||||||
<Form.Item label={t("general.labels.monday")} name={["workingdays", "monday"]} valuePropName="checked">
|
</Form.Item>
|
||||||
<Switch />
|
))}
|
||||||
</Form.Item>
|
</Space>
|
||||||
<Form.Item label={t("general.labels.tuesday")} name={["workingdays", "tuesday"]} valuePropName="checked">
|
</LayoutFormRow>
|
||||||
<Switch />
|
<Form.List name={["appt_colors"]}>
|
||||||
</Form.Item>
|
{(fields, { add, remove, move }) => {
|
||||||
<Form.Item label={t("general.labels.wednesday")} name={["workingdays", "wednesday"]} valuePropName="checked">
|
return (
|
||||||
<Switch />
|
<LayoutFormRow
|
||||||
</Form.Item>
|
header={t("bodyshop.labels.apptcolors")}
|
||||||
<Form.Item label={t("general.labels.thursday")} name={["workingdays", "thursday"]} valuePropName="checked">
|
id="apptcolors"
|
||||||
<Switch />
|
actions={[
|
||||||
</Form.Item>
|
<Button
|
||||||
<Form.Item label={t("general.labels.friday")} name={["workingdays", "friday"]} valuePropName="checked">
|
key="add-appointment-color"
|
||||||
<Switch />
|
type="primary"
|
||||||
</Form.Item>
|
block
|
||||||
<Form.Item label={t("general.labels.saturday")} name={["workingdays", "saturday"]} valuePropName="checked">
|
onClick={() => {
|
||||||
<Switch />
|
add({
|
||||||
</Form.Item>
|
color: {
|
||||||
</Space>
|
...DEFAULT_TRANSLUCENT_PICKER_COLOR,
|
||||||
<LayoutFormRow header={t("bodyshop.labels.apptcolors")} id="apptcolors">
|
rgb: { ...DEFAULT_TRANSLUCENT_PICKER_COLOR.rgb }
|
||||||
<Form.List name={["appt_colors"]}>
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.addapptcolor")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addapptcolor")} />
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
const appointmentColor =
|
||||||
|
appointmentColors[field.name] || form.getFieldValue(["appt_colors", field.name]) || {};
|
||||||
|
const appointmentColorSurfaceStyles = getTintedCardSurfaceStyles(appointmentColor.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle key={field.key}>
|
||||||
|
<InlineValidatedFormRow
|
||||||
|
form={form}
|
||||||
|
errorNames={[["appt_colors", field.name, "label"]]}
|
||||||
|
noDivider
|
||||||
|
title={
|
||||||
|
<div style={{ minWidth: 180, maxWidth: "100%" }}>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
key={`${index}aptcolorlabel`}
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("bodyshop.fields.appt_colors.label")}
|
||||||
|
style={SECTION_TITLE_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
{...appointmentColorSurfaceStyles}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
key={`${index}aptcolorcolor`}
|
||||||
|
name={[field.name, "color"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ColorpickerFormItemComponent styles={APPOINTMENT_COLOR_PICKER_STYLES} />
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.List>
|
||||||
|
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) && (
|
||||||
|
<Form.List name={["ssbuckets"]}>
|
||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow
|
||||||
{fields.map((field, index) => (
|
header={t("bodyshop.labels.ssbuckets")}
|
||||||
<Form.Item key={field.key}>
|
id="ssbuckets"
|
||||||
<LayoutFormRow noDivider>
|
actions={[
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.appt_colors.label")}
|
|
||||||
key={`${index}aptcolorlabel`}
|
|
||||||
name={[field.name, "label"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.appt_colors.color")}
|
|
||||||
key={`${index}aptcolorcolor`}
|
|
||||||
name={[field.name, "color"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ColorpickerFormItemComponent />
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
key="add-job-size-definition"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
add();
|
add({
|
||||||
|
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
>
|
||||||
{t("bodyshop.actions.addapptcolor")}
|
{t("bodyshop.actions.addbucket")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
]}
|
||||||
</div>
|
>
|
||||||
|
<div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addbucket")} />
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
const schedulingBucket =
|
||||||
|
schedulingBuckets[field.name] || form.getFieldValue(["ssbuckets", field.name]) || {};
|
||||||
|
const schedulingBucketSurfaceStyles = getTintedCardSurfaceStyles(schedulingBucket.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle key={field.key}>
|
||||||
|
<InlineValidatedFormRow
|
||||||
|
form={form}
|
||||||
|
errorNames={[
|
||||||
|
["ssbuckets", field.name, "id"],
|
||||||
|
["ssbuckets", field.name, "label"]
|
||||||
|
]}
|
||||||
|
noDivider
|
||||||
|
title={
|
||||||
|
<div style={SECTION_TITLE_INPUT_ROW_STYLE}>
|
||||||
|
<div style={SECTION_TITLE_INPUT_GROUP_STYLE}>
|
||||||
|
<div style={SECTION_TITLE_INPUT_LABEL_STYLE}>{t("bodyshop.fields.ssbuckets.id")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
key={`${index}id`}
|
||||||
|
name={[field.name, "id"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("bodyshop.fields.ssbuckets.id")}
|
||||||
|
style={{
|
||||||
|
...SECTION_TITLE_INPUT_STYLE,
|
||||||
|
width: 72
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...SECTION_TITLE_INPUT_GROUP_STYLE,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={SECTION_TITLE_INPUT_LABEL_STYLE}>
|
||||||
|
{t("bodyshop.fields.ssbuckets.label")}
|
||||||
|
</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
key={`${index}label`}
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("bodyshop.fields.ssbuckets.label")}
|
||||||
|
style={{
|
||||||
|
...SECTION_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title={t("bodyshop.tooltips.reset-color")}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
form.setFieldValue(["ssbuckets", field.name, "color"]);
|
||||||
|
|
||||||
|
form.setFields([
|
||||||
|
{
|
||||||
|
name: ["ssbuckets", field.name, "color"],
|
||||||
|
touched: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
{...schedulingBucketSurfaceStyles}
|
||||||
|
>
|
||||||
|
<div className="shop-info-scheduling__bucket-card-body">
|
||||||
|
<div className="shop-info-scheduling__bucket-card-fields">
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.ssbuckets.gte")}
|
||||||
|
key={`${index}gte`}
|
||||||
|
name={[field.name, "gte"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber suffix="hrs" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.ssbuckets.lt")}
|
||||||
|
key={`${index}lt`}
|
||||||
|
name={[field.name, "lt"]}
|
||||||
|
>
|
||||||
|
<InputNumber suffix="hrs" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.ssbuckets.target")}
|
||||||
|
key={`${index}target`}
|
||||||
|
name={[field.name, "target"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="shop-info-scheduling__bucket-card-color">
|
||||||
|
<Form.Item key={`${index}color`} name={[field.name, "color"]}>
|
||||||
|
<ColorPicker styles={SCHEDULING_BUCKET_COLOR_PICKER_STYLES} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
</LayoutFormRow>
|
|
||||||
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) && (
|
|
||||||
<LayoutFormRow header={t("bodyshop.labels.ssbuckets")} id="ssbuckets">
|
|
||||||
<Form.List name={["ssbuckets"]}>
|
|
||||||
{(fields, { add, remove, move }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<Form.Item key={field.key}>
|
|
||||||
<LayoutFormRow noDivider>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.ssbuckets.id")}
|
|
||||||
key={`${index}id`}
|
|
||||||
name={[field.name, "id"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.ssbuckets.label")}
|
|
||||||
key={`${index}label`}
|
|
||||||
name={[field.name, "label"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.ssbuckets.gte")}
|
|
||||||
key={`${index}gte`}
|
|
||||||
name={[field.name, "gte"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.ssbuckets.lt")}
|
|
||||||
key={`${index}lt`}
|
|
||||||
name={[field.name, "lt"]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.ssbuckets.target")}
|
|
||||||
key={`${index}target`}
|
|
||||||
name={[field.name, "target"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Space orientation="horizontal">
|
|
||||||
<Form.Item
|
|
||||||
label={
|
|
||||||
<Space>
|
|
||||||
{t("bodyshop.fields.ssbuckets.color")}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
form.setFieldValue(["ssbuckets", field.name, "color"]);
|
|
||||||
|
|
||||||
form.setFields([
|
|
||||||
{
|
|
||||||
name: ["ssbuckets", field.name, "color"],
|
|
||||||
touched: true
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
key={`${index}color`}
|
|
||||||
name={[field.name, "color"]}
|
|
||||||
>
|
|
||||||
<ColorPicker />
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("bodyshop.actions.addbucket")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.List>
|
|
||||||
</LayoutFormRow>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.shop-info-scheduling__bucket-card-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-fields {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(92px, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-fields .ant-form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-color {
|
||||||
|
flex: 0 0 360px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 360px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-color .ant-form-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-color .ant-form-item-control,
|
||||||
|
.shop-info-scheduling__bucket-card-color .ant-form-item-control-input,
|
||||||
|
.shop-info-scheduling__bucket-card-color .ant-form-item-control-input-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.shop-info-scheduling__bucket-card-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-fields {
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-scheduling__bucket-card-color {
|
||||||
|
flex-basis: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.shop-info-scheduling__bucket-card-fields {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Select } from "antd";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./shop-info.section-navigator.styles.scss";
|
||||||
|
|
||||||
|
const HIGHLIGHT_CLASS = "shop-info-section-navigator__target--active";
|
||||||
|
|
||||||
|
export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const targetMapRef = useRef(new Map());
|
||||||
|
const highlightedTargetRef = useRef(null);
|
||||||
|
const [options, setOptions] = useState([]);
|
||||||
|
const [selectedSection, setSelectedSection] = useState(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tabsContainer = tabsRef.current;
|
||||||
|
if (!tabsContainer) return undefined;
|
||||||
|
|
||||||
|
let animationFrameId = 0;
|
||||||
|
|
||||||
|
const refreshOptions = () => {
|
||||||
|
const activePane = tabsContainer.querySelector(".ant-tabs-tabpane-active");
|
||||||
|
if (!activePane) {
|
||||||
|
targetMapRef.current = new Map();
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTargetMap = new Map();
|
||||||
|
const nextOptions = Array.from(activePane.querySelectorAll(".imex-form-row"))
|
||||||
|
.filter((card) => {
|
||||||
|
return shouldIncludeCardInNavigator(card, activePane);
|
||||||
|
})
|
||||||
|
.map((card, index) => {
|
||||||
|
const { title, depth, searchLabel } = getCardNavigatorInfo(card, activePane);
|
||||||
|
const value = `${activeTabKey}-shop-info-section-${index}`;
|
||||||
|
|
||||||
|
nextTargetMap.set(value, card);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: renderNavigatorOptionLabel(title, depth),
|
||||||
|
labelText: title,
|
||||||
|
searchLabel,
|
||||||
|
depth,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
targetMapRef.current = nextTargetMap;
|
||||||
|
setOptions((currentOptions) => (areOptionsEqual(currentOptions, nextOptions) ? currentOptions : nextOptions));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = requestAnimationFrame(refreshOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleRefresh();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(scheduleRefresh);
|
||||||
|
observer.observe(tabsContainer, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"]
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [activeTabKey, tabsRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearHighlightedTarget(highlightedTargetRef);
|
||||||
|
setSelectedSection(undefined);
|
||||||
|
}, [activeTabKey]);
|
||||||
|
|
||||||
|
const handleSectionChange = (value) => {
|
||||||
|
setSelectedSection(value);
|
||||||
|
|
||||||
|
clearHighlightedTarget(highlightedTargetRef);
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const target = targetMapRef.current.get(value);
|
||||||
|
if (target) {
|
||||||
|
target.classList.add(HIGHLIGHT_CLASS);
|
||||||
|
highlightedTargetRef.current = target;
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setSelectedSection(undefined);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-info-section-navigator">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
value={selectedSection}
|
||||||
|
placeholder={t("bodyshop.labels.jump_to_section")}
|
||||||
|
options={options}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
disabled={options.length === 0}
|
||||||
|
filterOption={(input, option) => option?.searchLabel?.toLowerCase().includes(input.toLowerCase())}
|
||||||
|
onChange={handleSectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnCardTitleNode(card) {
|
||||||
|
const headNode = Array.from(card.children).find((child) => child.classList?.contains("ant-card-head"));
|
||||||
|
return headNode?.querySelector(".ant-card-head-title");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnCardTitle(card) {
|
||||||
|
return getOwnCardTitleNode(card)?.textContent?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAncestorCards(card, activePane) {
|
||||||
|
const ancestors = [];
|
||||||
|
let currentCard = card.parentElement?.closest(".imex-form-row");
|
||||||
|
|
||||||
|
while (currentCard && activePane.contains(currentCard)) {
|
||||||
|
ancestors.push(currentCard);
|
||||||
|
currentCard = currentCard.parentElement?.closest(".imex-form-row");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ancestors.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardDepth(card, activePane) {
|
||||||
|
return getAncestorCards(card, activePane).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleCard(card) {
|
||||||
|
return card.offsetParent !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavigatorEligibleSubsection(card) {
|
||||||
|
return (
|
||||||
|
!card.classList.contains("imex-form-row--compact") &&
|
||||||
|
!card.classList.contains("imex-form-row--title-only") &&
|
||||||
|
!card.querySelector(":scope > .ant-card-actions")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeCardInNavigator(card, activePane) {
|
||||||
|
const title = getOwnCardTitle(card);
|
||||||
|
if (!title || !isVisibleCard(card)) return false;
|
||||||
|
|
||||||
|
const depth = getCardDepth(card, activePane);
|
||||||
|
if (depth === 0) return true;
|
||||||
|
if (depth === 1) return isNavigatorEligibleSubsection(card);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardNavigatorInfo(card, activePane) {
|
||||||
|
const title = getOwnCardTitle(card);
|
||||||
|
const ancestors = getAncestorCards(card, activePane);
|
||||||
|
const depth = ancestors.length;
|
||||||
|
const parentTitle = depth === 1 ? getOwnCardTitle(ancestors[0]) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
depth,
|
||||||
|
searchLabel: parentTitle ? `${parentTitle} ${title}` : title
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNavigatorOptionLabel(title, depth) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"shop-info-section-navigator__option",
|
||||||
|
depth > 0 ? "shop-info-section-navigator__option--subsection" : null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<span className="shop-info-section-navigator__option-label">{title}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlightedTarget(highlightedTargetRef) {
|
||||||
|
if (highlightedTargetRef.current) {
|
||||||
|
highlightedTargetRef.current.classList.remove(HIGHLIGHT_CLASS);
|
||||||
|
highlightedTargetRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areOptionsEqual(currentOptions, nextOptions) {
|
||||||
|
if (currentOptions.length !== nextOptions.length) return false;
|
||||||
|
|
||||||
|
return currentOptions.every((option, index) => {
|
||||||
|
const nextOption = nextOptions[index];
|
||||||
|
return (
|
||||||
|
option.labelText === nextOption.labelText &&
|
||||||
|
option.searchLabel === nextOption.searchLabel &&
|
||||||
|
option.depth === nextOption.depth &&
|
||||||
|
option.value === nextOption.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.shop-info-section-navigator {
|
||||||
|
max-width: 360px;
|
||||||
|
width: min(360px, 100%);
|
||||||
|
|
||||||
|
.ant-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-section-navigator__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-section-navigator__option--subsection {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-section-navigator__option--subsection::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 50%;
|
||||||
|
width: 8px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ant-colorTextDescription);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-info-section-navigator__option-label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imex-form-row.shop-info-section-navigator__target--active.ant-card {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--ant-colorPrimary, #1890ff) 65%,
|
||||||
|
var(--imex-form-surface-border)
|
||||||
|
);
|
||||||
|
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 7%, var(--imex-form-surface));
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 24%, transparent);
|
||||||
|
transition: border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
.ant-card-head {
|
||||||
|
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 12%, var(--imex-form-surface-head));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 7%, var(--imex-form-surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,23 @@ import { Button, Form, Input, Select, Space } from "antd";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TemplateList } from "../../utils/TemplateConstants";
|
import { TemplateList } from "../../utils/TemplateConstants";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
INLINE_TITLE_SEPARATOR_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
|
|
||||||
export default function ShopInfoSpeedPrint() {
|
export default function ShopInfoSpeedPrint() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
const allTemplates = TemplateList("job");
|
const allTemplates = TemplateList("job");
|
||||||
const TemplateListGenerated = InstanceRenderManager({
|
const TemplateListGenerated = InstanceRenderManager({
|
||||||
imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)),
|
imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)),
|
||||||
@@ -18,80 +30,131 @@ export default function ShopInfoSpeedPrint() {
|
|||||||
<Form.List name={["speedprint"]}>
|
<Form.List name={["speedprint"]}>
|
||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow
|
||||||
{fields.map((field, index) => (
|
header={t("bodyshop.labels.speedprint_configurations")}
|
||||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
actions={[
|
||||||
<LayoutFormRow grow>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.speedprint.id")}
|
|
||||||
key={`${index}id`}
|
|
||||||
name={[field.name, "id"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.speedprint.label")}
|
|
||||||
key={`${index}label`}
|
|
||||||
name={[field.name, "label"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name={[field.name, "templates"]}
|
|
||||||
label={t("bodyshop.fields.speedprint.templates")}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
type: "array"
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
options={Object.keys(TemplateListGenerated).map((key) => ({
|
|
||||||
value: TemplateListGenerated[key].key,
|
|
||||||
label: TemplateListGenerated[key].title
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
key="add-speedprint"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
add();
|
add();
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
>
|
||||||
{t("bodyshop.actions.addspeedprint")}
|
{t("bodyshop.actions.addspeedprint")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
]}
|
||||||
</div>
|
>
|
||||||
|
<div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addspeedprint")} />
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle key={field.key}>
|
||||||
|
<InlineValidatedFormRow
|
||||||
|
form={form}
|
||||||
|
errorNames={[
|
||||||
|
["speedprint", field.name, "id"],
|
||||||
|
["speedprint", field.name, "label"]
|
||||||
|
]}
|
||||||
|
noDivider
|
||||||
|
title={
|
||||||
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.speedprint.id")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "id"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("bodyshop.fields.speedprint.id")}
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.speedprint.label")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "label"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t("bodyshop.fields.speedprint.label")}
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, "templates"]}
|
||||||
|
label={t("bodyshop.fields.speedprint.templates")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
type: "array"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={Object.keys(TemplateListGenerated).map((key) => ({
|
||||||
|
value: TemplateListGenerated[key].key,
|
||||||
|
label: TemplateListGenerated[key].title
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { DeleteFilled } from "@ant-design/icons";
|
|||||||
import { Button, Checkbox, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
import { Button, Checkbox, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -16,12 +18,51 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
|
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 }) {
|
export function ShopInfoTaskPresets({ bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
const taskPresets = Form.useWatch(["md_tasks_presets", "presets"], form) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LayoutFormRow noDivider>
|
<LayoutFormRow header={t("bodyshop.labels.task_preset_options")}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.md_tasks_presets.enable_tasks")}
|
label={t("bodyshop.fields.md_tasks_presets.enable_tasks")}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -38,173 +79,216 @@ export function ShopInfoTaskPresets({ bodyshop }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
|
|
||||||
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
|
<Form.List
|
||||||
<Form.List name={["md_tasks_presets", "presets"]}>
|
name={["md_tasks_presets", "presets"]}
|
||||||
{(fields, { add, remove, move }) => {
|
rules={[
|
||||||
return (
|
{
|
||||||
|
validator: async (_, presets) => {
|
||||||
|
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
|
||||||
|
|
||||||
|
if (allocationErrors.length > 0) {
|
||||||
|
throw new Error(allocationErrors.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(fields, { add, remove, move }, { errors }) => {
|
||||||
|
return (
|
||||||
|
<LayoutFormRow
|
||||||
|
header={t("bodyshop.labels.md_tasks_presets")}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-task-preset"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
add();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bodyshop.actions.add_task_preset")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.length === 0 ? (
|
||||||
<Form.Item key={field.key}>
|
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_task_preset")} />
|
||||||
<LayoutFormRow noDivider>
|
) : (
|
||||||
<Form.Item
|
fields.map((field, index) => {
|
||||||
label={t("bodyshop.fields.md_tasks_presets.name")}
|
const taskPreset = taskPresets[field.name] || {};
|
||||||
key={`${index}name`}
|
|
||||||
name={[field.name, "name"]}
|
return (
|
||||||
rules={[
|
<Form.Item key={field.key}>
|
||||||
{
|
<LayoutFormRow
|
||||||
required: true
|
noDivider
|
||||||
//message: t("general.validation.required"),
|
title={getFormListItemTitle(
|
||||||
|
t("bodyshop.fields.md_tasks_presets.name"),
|
||||||
|
index,
|
||||||
|
taskPreset.name,
|
||||||
|
taskPreset.memo
|
||||||
|
)}
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
]}
|
>
|
||||||
>
|
<Form.Item
|
||||||
<Input />
|
label={t("bodyshop.fields.md_tasks_presets.name")}
|
||||||
|
key={`${index}name`}
|
||||||
|
name={[field.name, "name"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
span={12}
|
||||||
|
label={t("bodyshop.fields.md_tasks_presets.hourstype")}
|
||||||
|
key={`${index}hourstype`}
|
||||||
|
name={[field.name, "hourstype"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Checkbox.Group>
|
||||||
|
<Row>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAA" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAA")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAB" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAB")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAD" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAD")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAE" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAE")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAF" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAF")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAG" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAG")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAM" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAM")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAR" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAR")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAS" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAS")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LAU" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LAU")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LA1" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LA1")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LA2" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LA2")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LA3" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LA3")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Checkbox value="LA4" style={{ lineHeight: "32px" }}>
|
||||||
|
{t("joblines.fields.lbr_types.LA4")}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.md_tasks_presets.percent")}
|
||||||
|
key={`${index}percent`}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
name={[field.name, "percent"]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.md_tasks_presets.memo")}
|
||||||
|
key={`${index}memo`}
|
||||||
|
name={[field.name, "memo"]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.md_tasks_presets.nextstatus")}
|
||||||
|
key={`${index}nextstatus`}
|
||||||
|
name={[field.name, "nextstatus"]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={bodyshop.md_ro_statuses.production_statuses.map((o) => ({
|
||||||
|
value: o,
|
||||||
|
label: o
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
);
|
||||||
span={12}
|
})
|
||||||
label={t("bodyshop.fields.md_tasks_presets.hourstype")}
|
)}
|
||||||
key={`${index}hourstype`}
|
<Form.ErrorList errors={errors} />
|
||||||
name={[field.name, "hourstype"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Checkbox.Group>
|
|
||||||
<Row>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAA" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAA")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAB" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAB")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAD" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAD")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAE" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAE")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAF" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAF")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAG" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAG")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAM" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAM")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAR" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAR")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAS" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAS")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LAU" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LAU")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LA1" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LA1")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LA2" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LA2")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LA3" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LA3")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
<Col span={4}>
|
|
||||||
<Checkbox value="LA4" style={{ lineHeight: "32px" }}>
|
|
||||||
{t("joblines.fields.lbr_types.LA4")}
|
|
||||||
</Checkbox>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Checkbox.Group>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_tasks_presets.percent")}
|
|
||||||
key={`${index}percent`}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
name={[field.name, "percent"]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} max={100} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_tasks_presets.memo")}
|
|
||||||
key={`${index}memo`}
|
|
||||||
name={[field.name, "memo"]}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.md_tasks_presets.nextstatus")}
|
|
||||||
key={`${index}nextstatus`}
|
|
||||||
name={[field.name, "nextstatus"]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={bodyshop.md_ro_statuses.production_statuses.map((o) => ({
|
|
||||||
value: o,
|
|
||||||
label: o
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("bodyshop.actions.add_task_preset")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</LayoutFormRow>
|
||||||
}}
|
);
|
||||||
</Form.List>
|
}}
|
||||||
</LayoutFormRow>
|
</Form.List>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -17,19 +18,22 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
|
|||||||
// noinspection JSUnusedLocalSymbols
|
// noinspection JSUnusedLocalSymbols
|
||||||
export function ShopInfoIntellipay({ bodyshop, form }) {
|
export function ShopInfoIntellipay({ bodyshop, form }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const cashDiscountEnabled = Form.useWatch(["intellipay_config", "enable_cash_discount"], form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
|
{cashDiscountEnabled && (
|
||||||
{() => {
|
<div style={{ marginBottom: 12 }}>
|
||||||
const { intellipay_config } = form.getFieldsValue();
|
<Alert title={t("bodyshop.labels.intellipay_cash_discount")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
if (intellipay_config?.enable_cash_discount)
|
<LayoutFormRow
|
||||||
return <Alert title={t("bodyshop.labels.intellipay_cash_discount")} />;
|
header={InstanceRenderManager({
|
||||||
}}
|
rome: t("bodyshop.labels.romepay"),
|
||||||
</Form.Item>
|
imex: t("bodyshop.labels.imexpay")
|
||||||
|
})}
|
||||||
<LayoutFormRow noDivider>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.intellipay_config.enable_cash_discount")}
|
label={t("bodyshop.fields.intellipay_config.enable_cash_discount")}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
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, Typography } from "antd";
|
||||||
|
|
||||||
import querystring from "query-string";
|
import querystring from "query-string";
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
@@ -11,9 +11,22 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
|
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import {
|
||||||
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
|
INLINE_TITLE_INPUT_STYLE,
|
||||||
|
INLINE_TITLE_LABEL_STYLE,
|
||||||
|
INLINE_TITLE_ROW_STYLE,
|
||||||
|
INLINE_TITLE_SEPARATOR_STYLE,
|
||||||
|
INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||||
|
InlineTitleListIcon
|
||||||
|
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
INSERT_EMPLOYEE_TEAM,
|
INSERT_EMPLOYEE_TEAM,
|
||||||
@@ -22,62 +35,181 @@ import {
|
|||||||
} from "../../graphql/employee_teams.queries";
|
} from "../../graphql/employee_teams.queries";
|
||||||
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
|
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import {
|
||||||
|
getSplitTotal,
|
||||||
|
hasExactSplitTotal,
|
||||||
|
LABOR_TYPES,
|
||||||
|
normalizeEmployeeTeam,
|
||||||
|
validateEmployeeTeamMembers
|
||||||
|
} from "./shop-employee-teams.form.utils.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = () => ({
|
const mapDispatchToProps = () => ({});
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
const PAYOUT_METHOD_OPTIONS = [
|
||||||
|
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
|
||||||
|
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
|
||||||
|
|
||||||
|
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, form, onDirtyChange, isDirty }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [internalForm] = Form.useForm();
|
||||||
|
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
||||||
|
const teamForm = form ?? internalForm;
|
||||||
|
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const search = querystring.parse(useLocation().search);
|
const search = querystring.parse(useLocation().search);
|
||||||
const notification = useNotification();
|
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, refetch } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
||||||
variables: { id: search.employeeTeamId },
|
variables: { id: search.employeeTeamId },
|
||||||
skip: !search.employeeTeamId || search.employeeTeamId === "new",
|
skip: !search.employeeTeamId || isNewTeam,
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only",
|
||||||
|
notifyOnNetworkStatusChange: true
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null;
|
||||||
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
|
|
||||||
else {
|
const updateDirtyState = useCallback(
|
||||||
form.resetFields();
|
(nextDirtyState) => {
|
||||||
|
setInternalIsDirty(nextDirtyState);
|
||||||
|
onDirtyChange?.(nextDirtyState);
|
||||||
|
},
|
||||||
|
[onDirtyChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearTeamFormMeta = useCallback(() => {
|
||||||
|
const fieldMeta = teamForm.getFieldsError().map(({ name }) => ({
|
||||||
|
name,
|
||||||
|
touched: false,
|
||||||
|
validating: false,
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fieldMeta.length > 0) {
|
||||||
|
teamForm.setFields(fieldMeta);
|
||||||
}
|
}
|
||||||
}, [form, data, search.employeeTeamId]);
|
|
||||||
|
updateDirtyState(false);
|
||||||
|
}, [teamForm, updateDirtyState]);
|
||||||
|
|
||||||
|
const resetTeamFormToCurrentData = useCallback(() => {
|
||||||
|
let hydrationFrameId;
|
||||||
|
|
||||||
|
teamForm.resetFields();
|
||||||
|
|
||||||
|
if (isNewTeam) {
|
||||||
|
setHydratedTeamId("new");
|
||||||
|
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
clearTeamFormMeta();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setHydratedTeamId(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTeamData) {
|
||||||
|
teamForm.setFieldsValue(normalizeEmployeeTeam(currentTeamData));
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
setHydratedTeamId(search.employeeTeamId);
|
||||||
|
clearTeamFormMeta();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
|
||||||
|
};
|
||||||
|
}, [clearTeamFormMeta, currentTeamData, isNewTeam, loading, search.employeeTeamId, teamForm]);
|
||||||
|
|
||||||
|
useEffect(() => resetTeamFormToCurrentData(), [resetTeamFormToCurrentData]);
|
||||||
|
|
||||||
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
||||||
const [insertEmployeeTeam] = useMutation(INSERT_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", teamForm);
|
||||||
|
const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || [];
|
||||||
|
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
|
||||||
|
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
|
||||||
|
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
|
||||||
|
const teamNameDisplay = teamName?.trim() || t("employee_teams.fields.name");
|
||||||
|
const teamCardTitle = isTeamHydrating ? (
|
||||||
|
t("employee_teams.fields.name")
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span>{teamNameDisplay}</span>
|
||||||
|
<span> - </span>
|
||||||
|
<Typography.Text type={isAllocationTotalExact ? undefined : "danger"}>
|
||||||
|
{t("employee_teams.labels.allocation_total", {
|
||||||
|
total: allocationTotalValue
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
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") {
|
if (search.employeeTeamId && search.employeeTeamId !== "new") {
|
||||||
//Update a record.
|
|
||||||
logImEXEvent("shop_employee_update");
|
logImEXEvent("shop_employee_update");
|
||||||
|
|
||||||
const result = await updateEmployeeTeam({
|
const result = await updateEmployeeTeam({
|
||||||
variables: {
|
variables: {
|
||||||
employeeTeamId: search.employeeTeamId,
|
employeeTeamId: search.employeeTeamId,
|
||||||
employeeTeam: values,
|
employeeTeam: values,
|
||||||
teamMemberUpdates: employee_team_members
|
teamMemberUpdates: normalizedTeamMembers
|
||||||
.filter((e) => e.id)
|
.filter((teamMember) => teamMember.id)
|
||||||
.map((e) => {
|
.map((teamMember) => ({
|
||||||
delete e.__typename;
|
where: { id: { _eq: teamMember.id } },
|
||||||
return { where: { id: { _eq: e.id } }, _set: e };
|
_set: teamMember
|
||||||
}),
|
})),
|
||||||
teamMemberInserts: employee_team_members
|
teamMemberInserts: normalizedTeamMembers
|
||||||
.filter((e) => e.id === null || e.id === undefined)
|
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
|
||||||
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
|
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
|
||||||
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
|
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
|
||||||
(e) => !employee_team_members.find((etm) => etm.id === e.id)
|
.filter(
|
||||||
)
|
(teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
|
||||||
|
)
|
||||||
|
.map((teamMember) => teamMember.id)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.errors) {
|
if (!result.errors) {
|
||||||
|
updateDirtyState(false);
|
||||||
|
void refetch();
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
});
|
});
|
||||||
@@ -89,20 +221,20 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
//New record, insert it.
|
|
||||||
logImEXEvent("shop_employee_insert");
|
logImEXEvent("shop_employee_insert");
|
||||||
|
|
||||||
insertEmployeeTeam({
|
insertEmployeeTeam({
|
||||||
variables: {
|
variables: {
|
||||||
employeeTeam: {
|
employeeTeam: {
|
||||||
...values,
|
...values,
|
||||||
employee_team_members: { data: employee_team_members },
|
employee_team_members: { data: normalizedTeamMembers },
|
||||||
bodyshopid: bodyshop.id
|
bodyshopid: bodyshop.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchQueries: ["QUERY_TEAMS"]
|
refetchQueries: ["QUERY_TEAMS"]
|
||||||
}).then((r) => {
|
}).then((response) => {
|
||||||
search.employeeTeamId = r.data.insert_employee_teams_one.id;
|
updateDirtyState(false);
|
||||||
|
search.employeeTeamId = response.data.insert_employee_teams_one.id;
|
||||||
history({ search: querystring.stringify(search) });
|
history({ search: querystring.stringify(search) });
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
@@ -116,288 +248,272 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
title={isTeamHydrating ? undefined : teamCardTitle}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => form.submit()}>
|
<Button
|
||||||
{t("general.actions.save")}
|
type="primary"
|
||||||
|
onClick={() => teamForm.submit()}
|
||||||
|
disabled={isTeamHydrating || !resolvedIsDirty}
|
||||||
|
style={{ minWidth: 190 }}
|
||||||
|
>
|
||||||
|
{t("employee_teams.actions.save_team")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
{isTeamHydrating ? (
|
||||||
<LayoutFormRow>
|
<Skeleton active title={false} paragraph={{ rows: 12 }} />
|
||||||
<Form.Item
|
) : (
|
||||||
name="name"
|
<Form
|
||||||
label={t("employee_teams.fields.name")}
|
onFinish={handleFinish}
|
||||||
rules={[
|
autoComplete={"off"}
|
||||||
{
|
layout="vertical"
|
||||||
required: true
|
form={teamForm}
|
||||||
//message: t("general.validation.required"),
|
onValuesChange={() => {
|
||||||
}
|
updateDirtyState(teamForm.isFieldsTouched());
|
||||||
]}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput />
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
add();
|
|
||||||
}}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{t("employee_teams.actions.newmember")}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
</Form.List>
|
>
|
||||||
</Form>
|
<FormsFieldChanged form={teamForm} onReset={resetTeamFormToCurrentData} onDirtyChange={updateDirtyState} />
|
||||||
|
<LayoutFormRow
|
||||||
|
title={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_ROW_STYLE,
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "var(--ant-font-size-lg)",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginRight: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("employee_teams.labels.team_options")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||||
|
marginLeft: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.active")}</div>
|
||||||
|
<Form.Item noStyle name="active" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t("employee_teams.fields.name")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("employee_teams.fields.max_load")}
|
||||||
|
name="max_load"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={1} suffix="%" />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
|
<Form.List name={["employee_team_members"]}>
|
||||||
|
{(fields, { add, remove, move }) => {
|
||||||
|
return (
|
||||||
|
<LayoutFormRow
|
||||||
|
title={t("employee_teams.labels.members")}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="add-team-member"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
add({
|
||||||
|
percentage: 0,
|
||||||
|
payout_method: "hourly",
|
||||||
|
labor_rates: {},
|
||||||
|
commission_rates: {}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("employee_teams.actions.newmember")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<ConfigListEmptyState actionLabel={t("employee_teams.actions.newmember")} />
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => {
|
||||||
|
return (
|
||||||
|
<Form.Item noStyle key={field.key}>
|
||||||
|
<Form.Item name={[field.name, "id"]} hidden>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
|
<InlineValidatedFormRow
|
||||||
|
form={teamForm}
|
||||||
|
errorNames={[
|
||||||
|
["employee_team_members", field.name, "employeeid"],
|
||||||
|
["employee_team_members", field.name, "percentage"],
|
||||||
|
["employee_team_members", field.name, "payout_method"]
|
||||||
|
]}
|
||||||
|
grow
|
||||||
|
title={
|
||||||
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.employeeid")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "employeeid"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.allocation")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
name={[field.name, "percentage"]}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
precision={2}
|
||||||
|
size="small"
|
||||||
|
aria-label={t("employee_teams.fields.allocation")}
|
||||||
|
suffix="%"
|
||||||
|
style={{
|
||||||
|
...INLINE_TITLE_INPUT_STYLE,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
|
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||||
|
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.payout_method")}</div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
key={`${index}-payout-method`}
|
||||||
|
name={[field.name, "payout_method"]}
|
||||||
|
initialValue="hourly"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
aria-label={t("employee_teams.fields.payout_method")}
|
||||||
|
size="small"
|
||||||
|
options={payoutMethodOptions}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
styles={{
|
||||||
|
selector: INLINE_TITLE_INPUT_STYLE
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
wrapTitle
|
||||||
|
extra={
|
||||||
|
<Space align="center" size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteFilled />}
|
||||||
|
onClick={() => {
|
||||||
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
dependencies={[["employee_team_members", field.name, "payout_method"]]}
|
||||||
|
>
|
||||||
|
{() => {
|
||||||
|
const payoutMethod =
|
||||||
|
teamForm.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} suffix="%" />
|
||||||
|
) : (
|
||||||
|
<CurrencyInput prefix="$" />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</InlineValidatedFormRow>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutFormRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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.actions.save_team": "Save Employee Team",
|
||||||
|
"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.labels.click_to_begin": `Click ${values.action ?? ""} to begin`,
|
||||||
|
"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("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
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, actions, children }) => (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{extra}
|
||||||
|
{children}
|
||||||
|
{actions}
|
||||||
|
</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 Employee Team" }));
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,20 +2,47 @@ import { Button } from "antd";
|
|||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||||
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
|
|
||||||
export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
|
export default function ShopEmployeeTeamsListComponent({
|
||||||
|
loading,
|
||||||
|
employee_teams,
|
||||||
|
onRequestTeamChange,
|
||||||
|
selectedTeamId
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
|
|
||||||
|
const navigateToTeam = (employeeTeamId) => {
|
||||||
|
if (onRequestTeamChange) {
|
||||||
|
onRequestTeamChange(employeeTeamId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...search,
|
||||||
|
employeeTeamId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTeamSelection = () => {
|
||||||
|
const { employeeTeamId, ...nextSearch } = search;
|
||||||
|
void employeeTeamId;
|
||||||
|
history({
|
||||||
|
search: queryString.stringify(nextSearch)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleOnRowClick = (record) => {
|
const handleOnRowClick = (record) => {
|
||||||
if (record) {
|
if (record) {
|
||||||
search.employeeTeamId = record.id;
|
navigateToTeam(record.id);
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
} else {
|
} else {
|
||||||
delete search.employeeTeamId;
|
clearTeamSelection();
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -27,43 +54,38 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow
|
||||||
<ResponsiveTable
|
title={t("bodyshop.labels.employee_teams")}
|
||||||
title={() => {
|
actions={[
|
||||||
return (
|
<Button key="new-team" type="primary" block onClick={() => navigateToTeam("new")}>
|
||||||
<Button
|
{t("employee_teams.actions.new")}
|
||||||
type="primary"
|
</Button>
|
||||||
onClick={() => {
|
]}
|
||||||
search.employeeTeamId = "new";
|
>
|
||||||
history({ search: queryString.stringify(search) });
|
{employee_teams.length === 0 ? (
|
||||||
}}
|
<ConfigListEmptyState actionLabel={t("employee_teams.actions.new")} />
|
||||||
>
|
) : (
|
||||||
{t("employee_teams.actions.new")}
|
<ResponsiveTable
|
||||||
</Button>
|
loading={loading}
|
||||||
);
|
pagination={{ placement: "top" }}
|
||||||
}}
|
columns={columns}
|
||||||
loading={loading}
|
mobileColumnKeys={["name"]}
|
||||||
pagination={{ placement: "top" }}
|
rowKey="id"
|
||||||
columns={columns}
|
dataSource={employee_teams}
|
||||||
mobileColumnKeys={["name"]}
|
rowSelection={{
|
||||||
rowKey="id"
|
onSelect: (props) => navigateToTeam(props.id),
|
||||||
dataSource={employee_teams}
|
type: "radio",
|
||||||
rowSelection={{
|
selectedRowKeys: [selectedTeamId || search.employeeTeamId]
|
||||||
onSelect: (props) => {
|
}}
|
||||||
search.employeeTeamId = props.id;
|
onRow={(record) => {
|
||||||
history({ search: queryString.stringify(search) });
|
return {
|
||||||
},
|
onClick: () => {
|
||||||
type: "radio",
|
handleOnRowClick(record);
|
||||||
selectedRowKeys: [search.employeeTeamId]
|
}
|
||||||
}}
|
};
|
||||||
onRow={(record) => {
|
}}
|
||||||
return {
|
/>
|
||||||
onClick: () => {
|
)}
|
||||||
handleOnRowClick(record);
|
</LayoutFormRow>
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,70 @@
|
|||||||
|
import { Form } from "antd";
|
||||||
import { useQuery } from "@apollo/client/react";
|
import { useQuery } from "@apollo/client/react";
|
||||||
|
import queryString from "query-string";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
|
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
|
||||||
|
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
|
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
|
||||||
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
|
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
|
||||||
import { Col, Row } from "antd";
|
import "./shop-teams.styles.scss";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({});
|
const mapStateToProps = createStructuredSelector({});
|
||||||
|
|
||||||
function ShopTeamsContainer() {
|
function ShopTeamsContainer() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [isTeamFormDirty, setIsTeamFormDirty] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const search = queryString.parse(useLocation().search);
|
||||||
const { loading, error, data } = useQuery(QUERY_TEAMS, {
|
const { loading, error, data } = useQuery(QUERY_TEAMS, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
const hasSelectedTeam = Boolean(search.employeeTeamId);
|
||||||
|
const hasDirtyTeamForm = Boolean(search.employeeTeamId) && isTeamFormDirty;
|
||||||
|
const confirmCloseDirtyTeam = useConfirmDirtyFormNavigation(hasDirtyTeamForm);
|
||||||
|
|
||||||
|
const navigateToTeam = (employeeTeamId) => {
|
||||||
|
if (employeeTeamId === search.employeeTeamId) return;
|
||||||
|
if (!confirmCloseDirtyTeam()) return;
|
||||||
|
|
||||||
|
setIsTeamFormDirty(false);
|
||||||
|
navigate({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...search,
|
||||||
|
employeeTeamId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<RbacWrapper action="employee_teams:page">
|
||||||
<RbacWrapper action="employee_teams:page">
|
<div
|
||||||
<Row gutter={[16, 16]}>
|
className={["shop-teams-layout", hasSelectedTeam ? "shop-teams-layout--with-detail" : null]
|
||||||
<Col span={6}>
|
.filter(Boolean)
|
||||||
<ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} />
|
.join(" ")}
|
||||||
</Col>
|
>
|
||||||
<Col span={18}>
|
<div className="shop-teams-layout__list">
|
||||||
<ShopEmployeeTeamsFormComponent />
|
<ShopEmployeeTeamsListComponent
|
||||||
</Col>
|
employee_teams={data ? data.employee_teams : []}
|
||||||
</Row>
|
loading={loading}
|
||||||
</RbacWrapper>
|
onRequestTeamChange={navigateToTeam}
|
||||||
</div>
|
selectedTeamId={search.employeeTeamId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasSelectedTeam ? (
|
||||||
|
<div className="shop-teams-layout__details">
|
||||||
|
<ShopEmployeeTeamsFormComponent form={form} onDirtyChange={setIsTeamFormDirty} isDirty={isTeamFormDirty} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</RbacWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
client/src/components/shop-teams/shop-teams.styles.scss
Normal file
16
client/src/components/shop-teams/shop-teams.styles.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.shop-teams-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-teams-layout__list,
|
||||||
|
.shop-teams-layout__details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1700px) {
|
||||||
|
.shop-teams-layout--with-detail {
|
||||||
|
grid-template-columns: minmax(420px, 500px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { QUERY_SHOP_ASSOCIATIONS } from "../../graphql/user.queries";
|
import { QUERY_SHOP_ASSOCIATIONS } from "../../graphql/user.queries";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import ShopUsersAuthEdit from "../shop-users-auth-edit/shop-users-auth-edit.component";
|
import ShopUsersAuthEdit from "../shop-users-auth-edit/shop-users-auth-edit.component";
|
||||||
@@ -66,7 +67,7 @@ export function ShopInfoUsersComponent({ bodyshop }) {
|
|||||||
return <AlertComponent type="error" title={JSON.stringify(error)} />;
|
return <AlertComponent type="error" title={JSON.stringify(error)} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<LayoutFormRow title={t("bodyshop.labels.licensing")}>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ placement: "top" }}
|
pagination={{ placement: "top" }}
|
||||||
@@ -75,6 +76,6 @@ export function ShopInfoUsersComponent({ bodyshop }) {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
dataSource={data && data.associations}
|
dataSource={data && data.associations}
|
||||||
/>
|
/>
|
||||||
</div>
|
</LayoutFormRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user