Compare commits

..

20 Commits

Author SHA1 Message Date
Dave
745eb8e980 Revert "Merged in feature/IO-2433-esignature (pull request #3133)"
This reverts commit af52c35013, reversing
changes made to 36157d87bb.
2026-03-17 10:44:36 -04:00
Dave
0b772133b8 feature/IO-3587-Commision-Cut - Additional test, layout enhancements 2026-03-17 10:40:14 -04:00
Dave
318a3be786 feature/IO-3614-March-2026-Tech-Debt - GraphQL-Request backend package bump 2026-03-16 14:18:36 -04:00
Dave
665f09d832 feature/IO-3614-March-2026-Tech-Debt - GraphQL-Request backend package bump 2026-03-16 11:13:15 -04:00
Dave
3d7f2961fd Tests + Packages + Vite 2026-03-16 11:02:48 -04:00
Dave Richer
af52c35013 Merged in feature/IO-2433-esignature (pull request #3133)
Feature/IO-2433 esignature
2026-03-14 01:17:41 +00:00
Dave Richer
36157d87bb Merged in feature/IO-3587-Commision-Cut (pull request #3132)
Feature/IO-3587 Commision Cut
2026-03-14 01:14:43 +00:00
Dave
722375fede feature/IO-3587-Commision-Cut - Remove some unrequired cleanup to reduce risk 2026-03-13 21:13:43 -04:00
Dave
339c19a041 Merge branch 'master-AIO' into feature/IO-3587-Commision-Cut 2026-03-13 21:05:36 -04:00
Dave Richer
b8570f3ae9 Merged in release/2026-03-13 (pull request #3130)
IO-3610 Export Log DMS Bug
2026-03-13 03:06:47 +00:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Dave
dd633cea89 hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency 2026-03-12 19:45:55 -04:00
Dave Richer
fb863c7979 Merged in release/2026-03-13 (pull request #3126)
IO-3585 saleClassValue fix
2026-03-12 20:34:21 +00:00
Dave Richer
8102fd5177 Merged in release/2026-03-13 (pull request #3120)
Release/2026 03 13
2026-03-12 16:39:36 +00:00
Dave
c7bb1a9c32 feature/IO-3587-Comission-Cut - Implement 2026-03-12 12:19:34 -04:00
Patrick Fic
97d8047a3d Update casing for esign route. 2026-03-05 15:56:13 -08:00
Patrick Fic
16220d0a27 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-03-04 15:01:25 -08:00
Patrick Fic
51fba24a3d IO-2433 Delete on cancel, improved styling. 2026-02-27 16:03:27 -08:00
Patrick Fic
52f43a600c IO-2433 Basic completion webhook, S3 upload, audit trail. 2026-02-27 15:44:23 -08:00
Patrick Fic
e25174ff97 IO-2433 Basic embedded authoring. 2026-02-27 13:15:10 -08:00
137 changed files with 7212 additions and 23345 deletions

3
.gitignore vendored
View File

@@ -142,6 +142,8 @@ 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
@@ -149,3 +151,4 @@ docker_data
/COPILOT.md /COPILOT.md
/.github/copilot-instructions.md /.github/copilot-instructions.md
/GEMINI.md /GEMINI.md
/_reference/select-component-test-plan.md

View File

@@ -1,62 +1,7 @@
This app connects to your Docker LocalStack endpoints and gives you a compact inspector for: This will connect to your dockers local stack session and render the email in HTML.
- 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
``` ```
Open: http://localhost:3334 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
```

View File

@@ -1,342 +1,96 @@
// index.js
import express from "express"; import express from "express";
import { readFileSync } from "node:fs"; import fetch from "node-fetch";
import { import { simpleParser } from "mailparser";
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 CLIENT_APP_PATH = new URL("./public/client-app.js", import.meta.url); const PORT = 3334;
const CLIENT_APP_SOURCE = readFileSync(CLIENT_APP_PATH, "utf8");
app.use((req, res, next) => { app.get("/", async (req, res) => {
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 {
res.json(await loadServiceHealthSummary()); const response = await fetch("http://localhost:4566/_aws/ses");
} catch (error) { if (!response.ok) {
console.error("Error fetching service health:", error); throw new Error("Network response was not ok");
res.status(502).json({ }
error: "Unable to fetch LocalStack service health", const data = await response.json();
details: error.message const messagesHtml = await parseMessages(data.messages);
}); res.send(renderHtml(messagesHtml));
}
});
app.get("/api/messages", async (req, res) => {
try {
res.json(await loadMessages());
} catch (error) { } catch (error) {
console.error("Error fetching messages:", error); console.error("Error fetching messages:", error);
res.status(502).json({ res.status(500).send("Error fetching messages");
error: "Unable to fetch messages from LocalStack SES",
details: error.message,
endpoint: SES_ENDPOINT
});
} }
}); });
app.get("/api/messages/:id/raw", async (req, res) => { async function parseMessages(messages) {
try { const parsedMessages = await Promise.all(
const message = await findSesMessageById(req.params.id); messages.map(async (message, index) => {
try {
const parsed = await simpleParser(message.RawData);
return `
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
<div class="mb-2"><span class="font-semibold">To:</span> ${parsed.to.text || "No To Address"}</div>
<div class="mb-2"><span class="font-semibold">Subject:</span> ${parsed.subject || "No Subject"}</div>
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
</div>
<div class="prose">${parsed.html || parsed.textAsHtml || "No HTML content available"}</div>
</div>
`;
} catch (error) {
console.error("Error parsing email:", error);
return `
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
<div class="text-red-500">Error parsing email content</div>
</div>
`;
}
})
);
return parsedMessages.join("");
}
if (!message) { function renderHtml(messagesHtml) {
res.status(404).type("text/plain").send("Message not found"); return `
return; <!DOCTYPE html>
} <html lang="en">
<head>
res.type("text/plain").send(message.RawData || ""); <meta charset="UTF-8">
} catch (error) { <meta name="viewport" content="width=device-width, initial-scale=1.0">
console.error("Error fetching raw message:", error); <title>Email Messages Viewer</title>
res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`); <script src="https://cdn.tailwindcss.com"></script>
} <style>
}); body {
background-color: #f3f4f6;
app.get("/api/messages/:id/attachments/:index", async (req, res) => { font-family: Arial, sans-serif;
try { }
const attachmentIndex = Number.parseInt(req.params.index, 10); .container {
max-width: 800px;
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) { margin: 50px auto;
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer"); padding: 20px;
return; }
} .prose {
line-height: 1.6;
const attachment = await loadMessageAttachment(req.params.id, attachmentIndex); }
</style>
if (!attachment) { </head>
res.status(404).type("text/plain").send("Attachment not found"); <body>
return; <div class="container bg-white shadow-lg rounded-lg p-6">
} <h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
<div id="messages-container">${messagesHtml}</div>
res.setHeader("Content-Type", attachment.contentType); </div>
res.setHeader("Content-Disposition", buildAttachmentDisposition(attachment.filename)); </body>
res.setHeader("Content-Length", String(attachment.content.length)); </html>
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(`LocalStack inspector is running on http://localhost:${PORT}`); console.log(`Server 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})`);
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,13 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1"
"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": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3", "description": "",
"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"

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
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
});

View File

@@ -1,845 +0,0 @@
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
};

View File

@@ -1,495 +0,0 @@
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">&#8593;</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">&#8593;</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">&#8593;</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">&#8593;</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export { getClientConfig, renderHtml };

View File

@@ -1,424 +0,0 @@
# 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:

View File

@@ -1,647 +0,0 @@
# 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:** _________________

View File

@@ -2696,27 +2696,6 @@
</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>
@@ -3705,48 +3684,6 @@
</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>
@@ -3894,27 +3831,6 @@
</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>
@@ -8725,27 +8641,6 @@
</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>
@@ -17921,468 +17816,6 @@
<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>
@@ -18404,153 +17837,6 @@
</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>
@@ -21304,27 +20590,6 @@
</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>
@@ -23744,27 +23009,6 @@
</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>
@@ -24179,27 +23423,6 @@
<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>
@@ -58229,27 +57452,6 @@
</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>
@@ -58271,27 +57473,6 @@
</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

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"private": true, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "^2.38.0", "@amplitude/analytics-browser": "^2.35.3",
"@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.1.0", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.21", "@firebase/analytics": "^0.10.19",
"@firebase/app": "^0.14.10", "@firebase/app": "^0.14.8",
"@firebase/auth": "^1.12.2", "@firebase/auth": "^1.12.0",
"@firebase/firestore": "^4.13.0", "@firebase/firestore": "^4.11.0",
"@firebase/messaging": "^0.12.25", "@firebase/messaging": "^0.12.22",
"@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.3.5", "@sentry/cli": "^3.2.2",
"@sentry/react": "^10.47.0", "@sentry/react": "^10.40.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.5", "antd": "^6.3.1",
"apollo-link-logger": "^3.0.0", "apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.14.0", "axios": "^1.13.5",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"css-box-model": "^1.2.1", "css-box-model": "^1.2.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.19",
"dayjs-business-days2": "^1.3.3", "dayjs-business-days2": "^1.3.2",
"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.2", "graphql": "^16.13.0",
"graphql-ws": "^6.0.8", "graphql-ws": "^6.0.7",
"i18next": "^25.10.10", "i18next": "^25.8.13",
"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.41", "libphonenumber-js": "^1.12.38",
"lightningcss": "^1.32.0", "lightningcss": "^1.31.1",
"logrocket": "^12.1.0", "logrocket": "^12.0.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.364.4", "posthog-js": "^1.355.0",
"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.1.0", "react-cookie": "^8.0.1",
"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.3", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.6.6", "react-i18next": "^16.5.4",
"react-icons": "^5.6.0", "react-icons": "^5.5.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.5", "react-number-format": "^5.4.3",
"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.2", "react-router-dom": "^7.13.1",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.3", "react-virtuoso": "^4.18.1",
"recharts": "^3.8.1", "recharts": "^3.7.0",
"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.98.0", "sass": "^1.97.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"styled-components": "^6.3.12", "styled-components": "^6.3.11",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.2.0" "web-vitals": "^5.1.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.1", "@ant-design/icons": "^6.1.0",
"@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.59.1", "@dotenvx/dotenvx": "^1.52.0",
"@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.2", "browserslist": "^4.28.1",
"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.4.0", "globals": "^17.3.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"memfs": "^4.57.1", "memfs": "^4.56.10",
"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.6.0", "vite-plugin-babel": "^1.5.1",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.26.0", "vite-plugin-node-polyfills": "^0.25.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.1.2", "vitest": "^4.0.18",
"workbox-window": "^7.4.0" "workbox-window": "^7.4.0"
} }
} }

View File

@@ -1,11 +1,7 @@
import { Button, Card, Divider, Form, Space, Typography } from "antd"; import { Button } 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({});
@@ -13,109 +9,8 @@ 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

View File

@@ -1,5 +1,5 @@
import { Alert } from "antd"; import { Alert } from "antd";
export default function AlertComponent({ title, message, ...props }) { export default function AlertComponent(props) {
return <Alert {...props} title={title ?? message} />; return <Alert {...props} />;
} }

View File

@@ -1,118 +0,0 @@
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);

View File

@@ -13,7 +13,6 @@ 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";
@@ -135,16 +134,10 @@ 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 ?? data?.bills_by_pk?.jobid, jobid: bill.jobid,
billid: search.billid, billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number, details), operation: AuditTrailMapping.billupdated(bill.invoice_number),
type: "billupdated" type: "billupdated"
}); });

View File

@@ -23,8 +23,7 @@ function BillEnterAiScan({
fileInputRef, fileInputRef,
scanLoading, scanLoading,
setScanLoading, setScanLoading,
setIsAiScan, setIsAiScan
setRawAIData
}) { }) {
const notification = useNotification(); const notification = useNotification();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -58,7 +57,6 @@ 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);
@@ -149,7 +147,6 @@ 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({

View File

@@ -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, Divider, Form, Modal, Space } from "antd"; import { Button, Checkbox, 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,7 +28,6 @@ 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,
@@ -54,7 +53,6 @@ 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();
@@ -389,7 +387,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
billlines: [] billlines: []
}); });
setIsAiScan(false); setIsAiScan(false);
setRawAIData(null);
// form.resetFields(); // form.resetFields();
} else { } else {
toggleModalVisible(); toggleModalVisible();
@@ -407,7 +404,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
} }
setScanLoading(false); setScanLoading(false);
setIsAiScan(false); setIsAiScan(false);
setRawAIData(null);
toggleModalVisible(); toggleModalVisible();
} }
}; };
@@ -433,7 +429,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
} }
setScanLoading(false); setScanLoading(false);
setIsAiScan(false); setIsAiScan(false);
setRawAIData(null);
} }
}, [billEnterModal.open, form, formValues]); }, [billEnterModal.open, form, formValues]);
@@ -461,7 +456,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
scanLoading={scanLoading} scanLoading={scanLoading}
setScanLoading={setScanLoading} setScanLoading={setScanLoading}
setIsAiScan={setIsAiScan} setIsAiScan={setIsAiScan}
setRawAIData={setRawAIData}
/> />
)} )}
</Space> </Space>
@@ -477,34 +471,26 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
setLoading(false); setLoading(false);
}} }}
footer={ footer={
<Space orientation="vertical"> <Space>
{isAiScan && ( <Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
<> {t("bills.labels.generatepartslabel")}
<BillAiFeedback billForm={form} rawAIData={rawAIData} /> </Checkbox>
<Divider orientation="horizontal" /> <Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
</> <Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
)} {t("general.actions.save")}
<Space wrap align="top"> </Button>
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}> {billEnterModal.context && billEnterModal.context.id ? null : (
{t("bills.labels.generatepartslabel")} <Button
</Checkbox> type="primary"
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button> loading={loading}
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal"> onClick={() => {
{t("general.actions.save")} setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button> </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

View File

@@ -52,7 +52,6 @@ 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 }
@@ -119,7 +118,6 @@ export function BillFormComponent({
} }
}, [ }, [
form, form,
vendorIdFormWatch,
billEdit, billEdit,
loadOutstandingReturns, loadOutstandingReturns,
loadInventory, loadInventory,

View File

@@ -96,7 +96,6 @@ 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"]);

View File

@@ -9,20 +9,18 @@ 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 = (dispatch) => ({ const mapDispatchToProps = () => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton); export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton);
export function BillMarkForReexportButton({ bodyshop, authLevel, bill, insertAuditTrail }) { export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const notification = useNotification(); const notification = useNotification();
@@ -49,12 +47,6 @@ export function BillMarkForReexportButton({ bodyshop, authLevel, bill, insertAud
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", {

View File

@@ -10,7 +10,6 @@ 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
@@ -157,127 +156,104 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
joblines: { joblines: {
data: billingLines data: billingLines
}, },
...InstanceRenderManager({ parts_tax_rates: {
imex: { PAA: {
parts_tax_rates: { prt_type: "PAA",
PAA: { prt_discp: 0,
prt_type: "PAA", prt_mktyp: false,
prt_discp: 0, prt_mkupp: 0,
prt_mktyp: false, prt_tax_in: true,
prt_mkupp: 0, prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
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
}
}
}, },
rome: { PAC: {
cieca_pft: { prt_type: "PAC",
...bodyshop.md_responsibility_centers.taxes.tax_ty1, prt_discp: 0,
...bodyshop.md_responsibility_centers.taxes.tax_ty2, prt_mktyp: false,
...bodyshop.md_responsibility_centers.taxes.tax_ty3, prt_mkupp: 0,
...bodyshop.md_responsibility_centers.taxes.tax_ty4, prt_tax_in: true,
...bodyshop.md_responsibility_centers.taxes.tax_ty5 prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
}, },
materials: bodyshop.md_responsibility_centers.cieca_pfm, PAL: {
cieca_pfl: bodyshop.md_responsibility_centers.cieca_pfl, prt_type: "PAL",
parts_tax_rates: bodyshop.md_responsibility_centers.parts_tax_rates, prt_discp: 0,
tax_tow_rt: bodyshop.md_responsibility_centers.tax_tow_rt, prt_mktyp: false,
tax_str_rt: bodyshop.md_responsibility_centers.tax_str_rt, prt_mkupp: 0,
tax_paint_mat_rt: bodyshop.md_responsibility_centers.tax_paint_mat_rt, prt_tax_in: true,
tax_shop_mat_rt: bodyshop.md_responsibility_centers.tax_shop_mat_rt, prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
tax_sub_rt: bodyshop.md_responsibility_centers.tax_sub_rt, },
tax_lbr_rt: bodyshop.md_responsibility_centers.tax_lbr_rt, PAM: {
tax_levies_rt: bodyshop.md_responsibility_centers.tax_levies_rt 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
} }
}) }
}; };
if (currentUser?.email) { if (currentUser?.email) {
@@ -311,7 +287,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(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`); history.push(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`);
} }
}); });
} }

View File

@@ -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 labor rows with bill hours / rates * - ROLABOR shell
* *
* 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,30 +181,21 @@ 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 return rolaborPreview.ops.map((op, idx) => {
.filter((op) => const rowOpCode = opCode || op.opCode;
[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,
jobTotalHrs: op.bill?.jobTotalHrs, amtType: op.amount?.amtType,
billTime: op.bill?.billTime, custPrice: op.amount?.custPrice,
billRate: op.bill?.billRate, totalAmt: op.amount?.totalAmt
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)
@@ -254,9 +245,6 @@ 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" }
@@ -329,13 +317,12 @@ 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 labor rows RR will receive, including weighted bill hours and rates derived from the This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
job&apos;s labor lines.
</Typography.Paragraph> </Typography.Paragraph>
<ResponsiveTable <ResponsiveTable
pagination={false} pagination={false}
columns={rolaborColumns} columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]} mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
rowKey="key" rowKey="key"
dataSource={rolaborRows} dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }} locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -4,203 +4,20 @@ 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, onErrorNavigate, onReset, onDirtyChange }) { export default function FormsFieldChanged({ form, skipPrompt }) {
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 = () => {
if (onReset) { form.resetFields();
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 const errors = form.getFieldsError().filter((e) => e.errors.length > 0);
.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%", marginBottom: 10 }}> <Space orientation="vertical" style={{ width: "100%" }}>
<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"
@@ -222,35 +39,10 @@ export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, o
{errors.length > 0 && ( {errors.length > 0 && (
<AlertComponent <AlertComponent
type="error" type="error"
title={t("general.labels.validationerror")} message={t("general.labels.validationerror")}
description={ description={
<div className="form-fields-changed__error-groups"> <div>
{errorGroups.map((group) => ( <ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
<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

View File

@@ -4,47 +4,4 @@
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;
}
}
} }

View File

@@ -1,88 +1,11 @@
import { PhoneFilled } from "@ant-design/icons"; import { Input } from "antd";
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 }) {
* Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a return <Input ref={ref} {...props} />;
* 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;

View File

@@ -1,34 +0,0 @@
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;

View File

@@ -1,30 +0,0 @@
/**
* 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}`;
}

View File

@@ -14,20 +14,16 @@ 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, insertAuditTrail }) { function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
const { const {
treatments: { CriticalPartsScanning } treatments: { CriticalPartsScanning }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -78,11 +74,6 @@ 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", {
@@ -112,17 +103,6 @@ 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", {

View File

@@ -1,112 +0,0 @@
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"
});
});
});

View File

@@ -1,190 +0,0 @@
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."
});
});
});
});

View File

@@ -1,17 +0,0 @@
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 });

View File

@@ -1,11 +0,0 @@
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>
);
}

View File

@@ -1,89 +0,0 @@
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"
})
});

View File

@@ -1,47 +0,0 @@
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>
);
}

View File

@@ -1,6 +1,5 @@
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({
@@ -8,45 +7,32 @@ export default function LayoutFormRow({
children, children,
grow = false, grow = false,
noDivider = false, noDivider = false,
titleOnly = false, gutter = [16, 16], // Responsive gutter: horizontal, vertical
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);
const isCompactRow = noDivider; if (items.length === 0) return null;
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: isHeaderOnly ? 8 : isCompactRow ? 12 : 16, paddingInline: 16,
background: headBg, background: headBg
borderBottomColor: borderColor
}, },
body: { body: {
padding: hideBody ? 0 : isCompactRow ? 12 : 16, padding: 16,
display: hideBody ? "none" : undefined,
background: bg background: bg
} }
}, },
@@ -54,12 +40,28 @@ export default function LayoutFormRow({
); );
const baseCardStyle = { const baseCardStyle = {
marginBottom: isHeaderOnly ? "0" : isCompactRow ? "8px" : ".8rem", marginBottom: ".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:
@@ -123,32 +125,20 @@ export default function LayoutFormRow({
return ( return (
<Card <Card
{...cardProps} {...cardProps}
title={resolvedTitle} title={cardProps.title ?? title}
size={cardProps.size ?? "small"} size={cardProps.size ?? "small"}
variant={cardProps.variant ?? "outlined"} variant={cardProps.variant ?? "outlined"}
className={[ className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
"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}
> >
{!isHeaderOnly && <Row gutter={gutter} wrap {...rowProps}>
(items.length === 1 ? ( {items.map((child, idx) => (
items[0] <Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
) : ( {child}
<Row gutter={resolvedGutter} wrap {...rowProps}> </Col>
{items.map((child, idx) => (
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
{child}
</Col>
))}
</Row>
))} ))}
</Row>
</Card> </Card>
); );
} }
@@ -162,7 +152,6 @@ 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 || {}) }
}; };
@@ -172,7 +161,6 @@ 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 || {}) }
}; };

View File

@@ -13,12 +13,6 @@
--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 */
@@ -26,12 +20,6 @@ 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 {
@@ -50,111 +38,18 @@ 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) {
&:not(.imex-form-row--title-only) .ant-card-head { .ant-card-head {
padding-inline: 12px; padding-inline: 12px;
padding-block: 12px; padding-block: 12px;
} }
@@ -175,14 +70,6 @@ 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 {
@@ -190,24 +77,3 @@ 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);
}
}

View File

@@ -1,13 +1,12 @@
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 { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd"; import { 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";
@@ -51,7 +50,6 @@ 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 });
}; };
@@ -130,38 +128,10 @@ export function PartsOrderModalComponent({
{(fields, { remove, move }) => { {(fields, { remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => { {fields.map((field, index) => (
const partsOrderLine = partsOrderLines[field.name] || {}; <Form.Item required={false} key={field.key}>
<div style={{ display: "flex" }}>
return ( <LayoutFormRow grow noDivider style={{ flex: 1 }}>
<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")}
@@ -250,9 +220,20 @@ export function PartsOrderModalComponent({
</Form.Item> </Form.Item>
)} )}
</LayoutFormRow> </LayoutFormRow>
</Form.Item> <Space wrap size="small" align="center">
); <div>
})} <DeleteFilled
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
</div>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</div>
</Form.Item>
))}
</div> </div>
); );
}} }}

View File

@@ -1,11 +1,10 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, InputNumber, Select, Space, Typography } from "antd"; import { Form, Input, InputNumber, Select, 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({
@@ -16,7 +15,6 @@ 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>
@@ -44,43 +42,16 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
{(fields, { remove, move }) => { {(fields, { remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => { {fields.map((field, index) => (
const partsOrderLine = partsOrderLines[field.name] || {}; <Form.Item required={false} key={field.key}>
<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 <LayoutFormRow grow style={{ flex: 1 }}>
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`}
@@ -130,9 +101,16 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
</Form.Item> <DeleteFilled
); style={{ margin: "1rem" }}
})} onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</div>
</Form.Item>
))}
</div> </div>
); );
}} }}

View File

@@ -2,13 +2,10 @@ 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>
@@ -17,46 +14,31 @@ export default function PartsEmailPresetsComponent() {
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => { {fields.map((field, index) => (
const preset = emailPresets[field.name] || {}; <Form.Item key={field.key}>
<LayoutFormRow noDivider>
return ( <Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Form.Item key={field.key}> <Input />
<LayoutFormRow </Form.Item>
noDivider <Form.Item
title={getFormListItemTitle(t("general.labels.label"), index, preset.label, preset.emails)} label={t("bodyshop.labels.md_to_emails_emails")}
extra={ key={`${index}emails`}
<Space align="center" size="small"> name={[field.name, "emails"]}
<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"]}> <Select mode="tags" tokenSeparators={[",", ";"]} />
<Input /> </Form.Item>
</Form.Item>
<Form.Item <Space>
label={t("bodyshop.labels.md_to_emails_emails")} <DeleteFilled
key={`${index}emails`} onClick={() => {
name={[field.name, "emails"]} remove(field.name);
> }}
<Select mode="tags" tokenSeparators={[",", ";"]} /> />
</Form.Item> <FormListMoveArrows move={move} index={index} total={fields.length} />
</LayoutFormRow> </Space>
</Form.Item> </LayoutFormRow>
); </Form.Item>
})} ))}
<Form.Item> <Form.Item>
<Button <Button
type="dashed" type="dashed"

View File

@@ -2,13 +2,10 @@ 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>
@@ -17,49 +14,34 @@ export default function PartsLocationsComponent() {
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => { {fields.map((field, index) => (
const location = partsLocations[field.name]; <Form.Item key={field.key}>
<LayoutFormRow noDivider>
return ( <Form.Item
<Form.Item key={field.key}> className="imex-flex-row__margin"
<LayoutFormRow label={t("bodyshop.fields.partslocation")}
noDivider key={`${index}`}
title={getFormListItemTitle(t("bodyshop.fields.partslocation"), index, location)} name={[field.name]}
extra={ rules={[
<Space align="center" size="small"> {
<Button required: true
type="text" }
icon={<DeleteFilled />} ]}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
> >
<Form.Item <Input />
</Form.Item>
<Space wrap>
<DeleteFilled
className="imex-flex-row__margin" className="imex-flex-row__margin"
label={t("bodyshop.fields.partslocation")} onClick={() => {
key={`${index}`} remove(field.name);
name={[field.name]} }}
rules={[ />
{ <FormListMoveArrows move={move} index={index} total={fields.length} />
required: true </Space>
} </LayoutFormRow>
]} </Form.Item>
> ))}
<Input />
</Form.Item>
</LayoutFormRow>
</Form.Item>
);
})}
<Form.Item> <Form.Item>
<Button <Button
type="dashed" type="dashed"

View File

@@ -2,13 +2,10 @@ 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>
@@ -17,65 +14,45 @@ export default function PartsOrderCommentsComponent() {
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => { {fields.map((field, index) => (
const comment = orderComments[field.name] || {}; <Form.Item key={field.key}>
<LayoutFormRow noDivider>
return ( <Form.Item
<Form.Item key={field.key}> label={t("general.labels.label")}
<LayoutFormRow key={`${index}label`}
noDivider name={[field.name, "label"]}
title={getFormListItemTitle( rules={[
t("parts_orders.fields.comments"), {
index, required: true
comment.label, }
comment.comment ]}
)}
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 <Input />
label={t("general.labels.label")} </Form.Item>
key={`${index}label`} <Form.Item
name={[field.name, "label"]} label={t("parts_orders.fields.comments")}
rules={[ key={`${index}comment`}
{ name={[field.name, "comment"]}
required: true rules={[
} {
]} required: true
> }
<Input /> ]}
</Form.Item> >
<Form.Item <Input.TextArea autoSize />
label={t("parts_orders.fields.comments")} </Form.Item>
key={`${index}comment`}
name={[field.name, "comment"]} <Space wrap>
rules={[ <DeleteFilled
{ onClick={() => {
required: true remove(field.name);
} }}
]} />
> <FormListMoveArrows move={move} index={index} total={fields.length} />
<Input.TextArea autoSize /> </Space>
</Form.Item> </LayoutFormRow>
</LayoutFormRow> </Form.Item>
</Form.Item> ))}
);
})}
<Form.Item> <Form.Item>
<Button <Button
type="dashed" type="dashed"

View File

@@ -58,7 +58,6 @@ 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,

View File

@@ -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 PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter 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,7 +28,6 @@ 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);
@@ -272,24 +271,14 @@ 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) => render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
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) => render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
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"),
@@ -609,19 +598,7 @@ 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;

View File

@@ -244,7 +244,6 @@ 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,
@@ -271,7 +270,6 @@ 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,

View File

@@ -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, buttonProps }) { export default function ShopEmployeeAddVacation({ employee }) {
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, buttonProps }) {
return ( return (
<Popover content={overlay} open={visibility}> <Popover content={overlay} open={visibility}>
<Button loading={loading} disabled={!employee?.active} onClick={handleClick} {...buttonProps}> <Button loading={loading} disabled={!employee?.active} onClick={handleClick}>
{t("employees.actions.addvacation")} {t("employees.actions.addvacation")}
</Button> </Button>
</Popover> </Popover>

View File

@@ -1,10 +1,11 @@
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, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import { Button, Card, Form, Input, InputNumber, Select, 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 { useCallback, useEffect, useRef, useState } from "react"; import { useEffect } 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";
@@ -25,24 +26,9 @@ 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
@@ -51,38 +37,19 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { export function ShopEmployeesFormComponent({ bodyshop }) {
const submitActionRef = useRef("save");
const { t } = useTranslation(); const { t } = useTranslation();
const [internalIsDirty, setInternalIsDirty] = useState(false); const [form] = useForm();
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, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, { const { error, data } = 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 }
@@ -92,150 +59,56 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
const updateDirtyState = useCallback(
(nextDirtyState) => {
setInternalIsDirty(nextDirtyState);
onDirtyChange?.(nextDirtyState);
},
[onDirtyChange]
);
const client = useApolloClient(); const client = useApolloClient();
const clearEmployeeFormMeta = useCallback(() => {
const fieldMeta = form.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
form.setFields(fieldMeta);
}
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(() => { useEffect(() => {
resetEmployeeFormToCurrentData(); if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk);
}, [resetEmployeeFormToCurrentData, search.employeeId]); else {
form.resetFields();
}
}, [form, data, 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");
try { updateEmployee({
const result = await updateEmployee({ variables: {
variables: { id: search.employeeId,
id: search.employeeId, employee: {
employee: normalizedValues ...values,
user_email: values.user_email === "" ? null : values.user_email
} }
});
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
void refetch();
if (submitAction === "saveAndNew") {
navigateToEmployee("new");
} }
})
.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({
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => {
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)
})
}); });
} }
}; };
@@ -268,8 +141,6 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
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 },
@@ -297,365 +168,226 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
return ( return (
<Card <Card
title={employeeCardTitle}
extra={ extra={
<Space wrap> <Button type="primary" onClick={() => form.submit()}>
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}> {t("general.actions.save")}
{t("general.actions.saveandnew") || "Save and New"} </Button>
</Button>
<Button
type="primary"
onClick={() => submitEmployeeForm("save")}
disabled={!resolvedIsDirty}
style={{ minWidth: 170 }}
>
{t("employees.actions.save_employee")}
</Button>
</Space>
} }
> >
<Form <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
onFinish={handleFinish} <LayoutFormRow>
onFinishFailed={saveAndResetSubmitAction} <Form.Item
autoComplete={"off"} name="first_name"
layout="vertical" label={t("employees.fields.first_name")}
form={form} rules={[
onValuesChange={() => { {
updateDirtyState(form.isFieldsTouched()); required: true
}} //message: t("general.validation.required"),
> }
<FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} /> ]}
<LayoutFormRow >
title={ <Input />
<div </Form.Item>
style={{ <Form.Item
...INLINE_TITLE_ROW_STYLE, label={t("employees.fields.last_name")}
justifyContent: "space-between" name="last_name"
}} rules={[
> {
<div required: true
style={{ //message: t("general.validation.required"),
...INLINE_TITLE_TEXT_STYLE, }
marginRight: "auto" ]}
}} >
> <Input />
{t("bodyshop.labels.employee_options")} </Form.Item>
</div> <Form.Item
<div name="employee_number"
style={{ label={t("employees.fields.employee_number")}
display: "flex", validateTrigger="onBlur"
alignItems: "center", hasFeedback
gap: 4, rules={[
flexWrap: "wrap", {
marginLeft: "auto" required: true
}} //message: t("general.validation.required"),
> },
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} /> () => ({
<div async validator(rule, value) {
style={{ if (value) {
...INLINE_TITLE_SWITCH_GROUP_STYLE const response = await client.query({
}} query: CHECK_EMPLOYEE_NUMBER,
> variables: {
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.labels.active")}</div> employeenumber: value
<Form.Item noStyle valuePropName="checked" name="active">
<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("employees.fields.flat_rate")}</div>
<Form.Item noStyle valuePropName="checked" name="flat_rate">
<Switch />
</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>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
label={t("employees.fields.last_name")}
name="last_name"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
name="employee_number"
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();
} }
});
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> <Input />
<Col {...employeeOptionsColProps}> </Form.Item>
<Form.Item <Form.Item
name="hire_date" label={t("employees.fields.pin")}
label={t("employees.fields.hire_date")} name="pin"
rules={[ rules={[
{ {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<DateTimePicker isDateOnly /> <Input />
</Form.Item> </Form.Item>
</Col> </LayoutFormRow>
<Col {...employeeOptionsColProps}> <LayoutFormRow>
<Form.Item label={t("employees.fields.termination_date")} name="termination_date"> <Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active">
<DateTimePicker isDateOnly /> <Switch />
</Form.Item> </Form.Item>
</Col> <Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked">
<Col {...employeeOptionsColProps}> <Switch />
<Form.Item </Form.Item>
label={t("employees.fields.user_email")} <Form.Item
name="user_email" name="hire_date"
validateTrigger="onBlur" label={t("employees.fields.hire_date")}
rules={[ rules={[
({ getFieldValue }) => ({ {
async validator(rule, value) { required: true
const user_email = getFieldValue("user_email"); //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) { if (user_email && value) {
const response = await client.query({ const response = await client.query({
query: QUERY_USERS_BY_EMAIL, query: QUERY_USERS_BY_EMAIL,
variables: { variables: {
email: user_email email: user_email
}
});
if (response.data.users.length === 1) {
return Promise.resolve();
}
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
} else {
return Promise.resolve();
} }
});
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 /> <Input />
</Form.Item> </Form.Item>
</Col> <Form.Item label={t("employees.fields.external_id")} name="external_id">
</Row> <Input />
</Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.List name={["rates"]}> <Form.List name={["rates"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<LayoutFormRow <div>
title={t("bodyshop.labels.employee_rates")} {fields.map((field, index) => (
actions={[ <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<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
key="add-rate" type="dashed"
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.addrate")}</span> <span id="new-employee-rate">{t("employees.actions.newrate")}</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>
<LayoutFormRow <ResponsiveTable
title={t("bodyshop.labels.employee_vacation")} title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
actions={[ columns={columns}
<ShopEmployeeAddVacation mobileColumnKeys={["start", "length", "actions"]}
key="add-vacation" rowKey={"id"}
employee={data && data.employees_by_pk} dataSource={data?.employees_by_pk?.employee_vacations ?? []}
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>
); );
} }

View File

@@ -1,345 +0,0 @@
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"
});
});
});

View File

@@ -4,16 +4,9 @@ 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({ export default function ShopEmployeesListComponent({ loading, employees }) {
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);
@@ -23,33 +16,13 @@ export default function ShopEmployeesListComponent({
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) {
navigateToEmployee(record.id); search.employeeId = record.id;
history({ search: queryString.stringify(search) });
} else { } else {
clearEmployeeSelection(); delete search.employeeId;
history({ search: queryString.stringify(search) });
} }
}; };
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
@@ -57,7 +30,7 @@ export default function ShopEmployeesListComponent({
}; };
const columns = [ const columns = [
{ {
title: t("employees.labels.employee_number_short"), title: t("employees.fields.employee_number"),
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),
@@ -116,39 +89,44 @@ export default function ShopEmployeesListComponent({
} }
]; ];
return ( return (
<LayoutFormRow <div>
title={t("bodyshop.labels.employees")} <ResponsiveTable
actions={[ title={() => {
<Button key="new-employee" type="primary" block onClick={() => navigateToEmployee("new")}> return (
{t("employees.actions.new")} <Button
</Button> type="primary"
]} onClick={() => {
> search.employeeId = "new";
{employees.length === 0 ? ( history({ search: queryString.stringify(search) });
<ConfigListEmptyState actionLabel={t("employees.actions.new")} /> }}
) : ( >
<ResponsiveTable {t("employees.actions.new")}
loading={loading} </Button>
pagination={{ placement: "top" }} );
columns={columns} }}
mobileColumnKeys={["employee_number", "employee_name", "active"]} loading={loading}
rowKey="id" pagination={{ placement: "top" }}
dataSource={employees} columns={columns}
rowSelection={{ mobileColumnKeys={["employee_number", "employee_name", "active"]}
onSelect: (props) => navigateToEmployee(props.id), rowKey="id"
type: "radio", dataSource={employees}
selectedRowKeys: [selectedEmployeeId || search.employeeId] rowSelection={{
}} onSelect: (props) => {
onChange={handleTableChange} search.employeeId = props.id;
onRow={(record) => { history({ search: queryString.stringify(search) });
return { },
onClick: () => { type: "radio",
handleOnRowClick(record); selectedRowKeys: [search.employeeId]
} }}
}; onChange={handleTableChange}
}} onRow={(record) => {
/> return {
)} onClick: () => {
</LayoutFormRow> handleOnRowClick(record);
}
};
}}
/>
</div>
); );
} }

View File

@@ -1,101 +1,29 @@
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 (
<RbacWrapper action="employees:page"> <div>
<div className="shop-employees-layout"> <RbacWrapper action="employees:page">
<div className="shop-employees-layout__list"> <ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} />
<ShopEmployeesListComponent <ShopEmployeesFormComponent />
employees={data ? data.employees : []} </RbacWrapper>
loading={loading} </div>
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>
); );
} }

View File

@@ -1,7 +0,0 @@
.shop-employees-layout {
min-width: 0;
}
.shop-employees-layout__list {
min-width: 0;
}

View File

@@ -1,304 +0,0 @@
/**
* 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))`
};
};

View File

@@ -1,52 +0,0 @@
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))"
});
});
});

View File

@@ -1,7 +1,6 @@
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";
@@ -22,7 +21,6 @@ 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";
@@ -35,7 +33,7 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) { export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
const { const {
treatments: { CriticalPartsScanning, Enhanced_Payroll } treatments: { CriticalPartsScanning, Enhanced_Payroll }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -49,7 +47,6 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
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 = [
{ {
@@ -157,35 +154,23 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
] ]
: []) : [])
]; ];
const activeTabKey = search.subtab || tabItems[0]?.key;
return ( return (
<Card <Card
title={<ShopInfoSectionNavigator tabsRef={tabsRef} activeTabKey={activeTabKey} />}
extra={ extra={
<Button <Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button">
type="primary" {t("general.actions.save")}
disabled={!isDirty || saveLoading}
loading={saveLoading}
onClick={() => form.submit()}
id="shop-info-save-button"
style={{ minWidth: 210 }}
>
{t("bodyshop.actions.save_shop_information")}
</Button> </Button>
} }
> >
<div ref={tabsRef}> <Tabs
<Tabs defaultActiveKey={search.subtab}
activeKey={activeTabKey} onChange={(key) =>
onChange={(key) => history({
history({ search: `?tab=${search.tab}&subtab=${key}`
search: `?tab=${search.tab}&subtab=${key}` })
}) }
} items={tabItems}
items={tabItems} />
/>
</div>
</Card> </Card>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Card } from "antd"; import { Card, 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";
@@ -15,8 +15,9 @@ function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card title={t("settings.title")}> <Card>
<PhoneNumberConsentList bodyshop={bodyshop} /> <Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</Card> </Card>
); );
} }

View File

@@ -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, useMemo, useState } from "react"; import { useEffect, 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,7 +15,6 @@ 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, {
@@ -34,10 +33,7 @@ export default function ShopInfoContainer() {
return acc; return acc;
}, {}); }, {});
const combinedFeatureConfig = useMemo( const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters);
() => 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(
@@ -55,10 +51,7 @@ export default function ShopInfoContainer() {
}) })
.then(() => { .then(() => {
notification.success({ title: t("bodyshop.successes.save") }); notification.success({ title: t("bodyshop.successes.save") });
refetch().then(() => { refetch().then(() => form.resetFields());
form.resetFields();
setIsShopInfoDirty(false);
});
}) })
.catch((error) => { .catch((error) => {
notification.error({ notification.error({
@@ -73,7 +66,6 @@ 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" />;
@@ -84,9 +76,6 @@ 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
@@ -110,8 +99,8 @@ export default function ShopInfoContainer() {
: null : null
} }
> >
<FormsFieldChanged form={form} onDirtyChange={setIsShopInfoDirty} /> <FormsFieldChanged form={form} />
<ShopInfoComponent form={form} saveLoading={saveLoading} isDirty={isShopInfoDirty} /> <ShopInfoComponent form={form} saveLoading={saveLoading} />
</Form> </Form>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,7 @@ 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 {
@@ -31,386 +19,306 @@ 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>
<LayoutFormRow header={t("bodyshop.labels.intake_delivery")} id="intake-delivery"> <Form.Item
<Form.Item name={["intakechecklist", "templates"]}
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }} label={t("bodyshop.fields.intake.templates")}
name={["intakechecklist", "templates"]} rules={[
label={t("bodyshop.fields.intake.templates")} {
rules={[ required: true,
{ //message: t("general.validation.required"),
required: true, type: "array"
//message: t("general.validation.required"), }
type: "array" ]}
} >
]} <Select
> mode="multiple"
<Select options={Object.keys(TemplateListGenerated).map((i) => ({
mode="multiple" value: TemplateListGenerated[i].key,
options={Object.keys(TemplateListGenerated).map((i) => ({ label: TemplateListGenerated[i].title
value: TemplateListGenerated[i].key, }))}
label: TemplateListGenerated[i].title />
}))} </Form.Item>
/> <Form.Item
</Form.Item> name={["intakechecklist", "next_contact_hours"]}
<Form.Item label={t("bodyshop.fields.intake.next_contact_hours")}
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }} >
name={["deliverchecklist", "templates"]} <InputNumber min={0} precision={0} />
label={t("bodyshop.fields.deliver.templates")} </Form.Item>
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"]}>
{(fields, { add, remove, move }) => { <LayoutFormRow header={t("bodyshop.labels.deliverchecklist")} id="deliverchecklist">
return ( <Form.List name={["deliverchecklist", "form"]}>
<LayoutFormRow {(fields, { add, remove, move }) => {
header={t("bodyshop.labels.intakechecklist")} return (
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.length === 0 ? ( {fields.map((field, index) => (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_intake_checklist_item")} /> <Form.Item key={field.key}>
) : ( <LayoutFormRow noDivider>
fields.map((field, index) => { <Form.Item
return ( label={t("jobs.fields.intake.name")}
<Form.Item noStyle key={field.key}> key={`${index}named`}
<InlineValidatedFormRow name={[field.name, "name"]}
form={form} rules={[
errorNames={[["intakechecklist", "form", field.name, "name"]]} {
noDivider required: true
title={ //message: t("general.validation.required"),
<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={ >
<Space align="center" size="small"> <Input />
<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}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>
</InlineValidatedFormRow>
</Form.Item> </Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>
<Form.List name={["deliverchecklist", "form"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.deliverchecklist")}
id="deliverchecklist"
actions={[
<Button
key="add-delivery-checklist-item"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.add_delivery_checklist_item")}
</Button>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_delivery_checklist_item")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["deliverchecklist", "form", field.name, "name"]]}
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("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 <Form.Item
label={t("jobs.fields.intake.label")} label={t("jobs.fields.intake.type")}
key={`${index}labeld`} key={`${index}typed`}
name={[field.name, "label"]} name={[field.name, "type"]}
rules={[ rules={[
{ {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<Input /> <Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</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> </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>
<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>
</Form.List> </LayoutFormRow>
<SelectorDiv>
<Form.Item
name={["deliverchecklist", "templates"]}
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
name={["deliverchecklist", "actual_delivery"]}
label={t("bodyshop.fields.deliver.require_actual_delivery_date")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Switch />
</Form.Item>
</SelectorDiv>
</div> </div>
); );
} }

View File

@@ -3,392 +3,344 @@ 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 prefix="$" min={0} /> <CurrencyInput 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 prefix="$" min={0} /> <CurrencyInput min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.List name={["md_labor_rates"]}> <LayoutFormRow header={t("bodyshop.labels.laborrates")}>
{(fields, { add, remove, move }) => { <Form.List name={["md_labor_rates"]}>
return ( {(fields, { add, remove, move }) => {
<LayoutFormRow return (
header={t("bodyshop.labels.laborrates")}
actions={[
<Button
key="add-labor-rate"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
]}
>
<div> <div>
{fields.length === 0 ? ( {fields.map((field, index) => (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.newlaborrate")} /> <Form.Item key={field.key}>
) : ( <LayoutFormRow noDivider={index === 0}>
fields.map((field, index) => { <Form.Item
return ( label={t("jobs.fields.labor_rate_desc")}
<Form.Item noStyle key={field.key}> key={`${index}rate_label`}
<InlineValidatedFormRow name={[field.name, "rate_label"]}
form={form} rules={[
errorNames={[["md_labor_rates", field.name, "rate_label"]]}
noDivider={index === 0}
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={
<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.rate_laa")}
key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//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>
{ {
// <Form.Item required: true
// label={t("jobs.fields.rate_mabl")} //message: t("general.validation.required"),
// 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`} <Input />
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_laa")}
)} key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput 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 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 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>
</Form.List> </LayoutFormRow>
</> </>
); );
} }

View File

@@ -1,7 +1,6 @@
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;
@@ -12,45 +11,43 @@ 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 (
<LayoutFormRow header={t("bodyshop.labels.notification_options")}> <div>
<div> <Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph> <Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text> {employeeOptions.length > 0 ? (
{employeeOptions.length > 0 ? ( <Form.Item
<Form.Item normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")} name="notification_followers"
name="notification_followers" rules={[
rules={[ {
{ type: "array",
type: "array", message: t("general.validation.array")
message: t("general.validation.array") },
}, {
{ validator: async (_, value) => {
validator: async (_, value) => { if (!value || value.length === 0) {
if (!value || value.length === 0) { return Promise.resolve(); // Allow empty array
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 >
style={{ minWidth: "100%" }} <EmployeeSearchSelectComponent
mode="multiple" style={{ minWidth: "100%" }}
options={employeeOptions} mode="multiple"
placeholder={t("bodyshop.fields.notifications.placeholder")} options={employeeOptions}
showEmail={true} placeholder={t("bodyshop.fields.notifications.placeholder")}
/> 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> )}
</LayoutFormRow> </div>
); );
} }

View File

@@ -3,19 +3,7 @@ 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"];
@@ -80,223 +68,195 @@ export default function ShopInfoPartsScan({ form }) {
return ( return (
<div> <div>
<Form.List name={["md_parts_scan"]}> <LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
{(fields, { add, remove, move }) => ( <Form.List name={["md_parts_scan"]}>
<LayoutFormRow {(fields, { add, remove, move }) => (
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.length === 0 ? ( {fields.map((field, index) => {
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addpartsrule")} /> const selectedField = watchedFields?.[index]?.field || "line_desc";
) : ( const fieldType = getFieldType(selectedField);
fields.map((field, index) => {
const selectedField = watchedFields?.[index]?.field || "line_desc";
const fieldType = getFieldType(selectedField);
return ( return (
<Form.Item noStyle key={field.key}> <Form.Item key={field.key}>
<InlineValidatedFormRow <Row gutter={[16, 16]} align="middle">
form={form} {/* Select Field */}
errorNames={[["md_parts_scan", field.name, "field"]]} <Col span={6}>
noDivider <Form.Item
title={ label={t("bodyshop.fields.md_parts_scan.field")}
<div style={INLINE_TITLE_ROW_STYLE}> name={[field.name, "field"]}
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} /> rules={[
<div style={INLINE_TITLE_GROUP_STYLE}> {
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.md_parts_scan.field")}</div> required: true,
<Form.Item message: t("general.validation.required", {
noStyle label: t("bodyshop.fields.md_parts_scan.field")
name={[field.name, "field"]} })
rules={[ }
{ ]}
required: true, >
message: t("general.validation.required", { <Select
label: t("bodyshop.fields.md_parts_scan.field") options={fieldSelectOptions}
}) onChange={() => {
} form.setFields([
]} { name: ["md_parts_scan", index, "operation"], value: "contains" },
> { name: ["md_parts_scan", index, "value"], value: undefined }
<Select ]);
options={fieldSelectOptions} }}
onChange={() => { />
form.setFields([ </Form.Item>
{ name: ["md_parts_scan", index, "operation"], value: "contains" }, </Col>
{ 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>
)}
{/* Value */} {/* Operation */}
{fieldType && ( {fieldType !== "predefined" && fieldType && (
<Col span={6}> <Col span={6}>
<Form.Item <Form.Item
label={t("bodyshop.fields.md_parts_scan.value")} label={t("bodyshop.fields.md_parts_scan.operation")}
name={[field.name, "value"]} name={[field.name, "operation"]}
rules={[ rules={[
{ {
required: true, required: true,
message: t("general.validation.required", { message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value") label: t("bodyshop.fields.md_parts_scan.operation")
}) })
} }
]} ]}
> >
{fieldType === "predefined" ? ( <Select options={operationOptions[fieldType]} />
<Select </Form.Item>
options={ </Col>
selectedField === "part_type" )}
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
}
/>
) : (
<Input />
)}
</Form.Item>
</Col>
)}
{/* Update Field */} {/* Value */}
<Col span={4}> {fieldType && (
<Form.Item <Col span={6}>
label={t("bodyshop.fields.md_parts_scan.update_field")} <Form.Item
name={[field.name, "update_field"]} label={t("bodyshop.fields.md_parts_scan.value")}
> 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={fieldSelectOptions} options={
allowClear selectedField === "part_type"
onClear={() => ? predefinedPartTypes.map((type) => ({
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }]) label: type,
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> )}
</Col> </Form.Item>
</Row> </Col>
</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>
</Form.List> </LayoutFormRow>
</div> </div>
); );
} }

View File

@@ -27,7 +27,7 @@ export function ShopInfoRbacComponent({ bodyshop }) {
}); });
return ( return (
<RbacWrapper action="shop:rbac"> <RbacWrapper action="shop:rbac">
<LayoutFormRow header={t("bodyshop.labels.rbac_options")}> <LayoutFormRow>
{[ {[
...(HasFeatureAccess({ featureName: "export", bodyshop }) ...(HasFeatureAccess({ featureName: "export", bodyshop })
? [ ? [

View File

@@ -1,4 +1,4 @@
import { Col, Collapse, Form, Input, InputNumber, Row, Switch } from "antd"; import { Collapse, Divider, Form, Input, InputNumber, Space, 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,7 +6,6 @@ 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
@@ -17,102 +16,53 @@ 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();
const profileTaxCards = []; //Iteratively build the form items.
for (let typeNum = 1; typeNum <= 5; typeNum++) { const formItems = [];
const rootTaxItems = getRootTaxFormItems({ typeNum, bodyshop, t }); for (let tyCounter = 1; tyCounter <= 5; tyCounter++) {
const section = [];
profileTaxCards.push( section.push(
<LayoutFormRow key={`profile-tax-type-${typeNum}`} header={t("bodyshop.labels.responsibilitycenters.tax_type_card", { typeNum })}> TaxFormItems({
<div style={{ display: "grid", rowGap: 12 }}> typeNum: tyCounter,
<Row gutter={[16, 16]} wrap> rootElements: true,
{rootTaxItems.map((item, index) => ( bodyshop
<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 (
<> <>
<LayoutFormRow header={t("jobs.labels.cieca_pft")}> <Divider titlePlacement="left" orientation="horizontal" style={{ marginTop: ".8rem" }}>
<div>{profileTaxCards}</div> {t("jobs.labels.cieca_pft")}
</LayoutFormRow> </Divider>
{formItems}
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.default_tax_setup")}> <Collapse
<Collapse items={[
items={[ {
{ key: "cieca_pfl",
key: "cieca_pfl", label: t("jobs.labels.cieca_pfl"),
label: t("jobs.labels.cieca_pfl"), forceRender: true,
forceRender: true, children: (
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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -139,7 +89,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -185,7 +135,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -212,7 +162,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -258,7 +208,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -285,7 +235,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -331,7 +281,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -358,7 +308,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -404,7 +354,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -431,7 +381,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -477,7 +427,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -504,7 +454,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -550,7 +500,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -577,7 +527,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -623,7 +573,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</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")}
@@ -723,7 +673,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={2} suffix="%" /> <InputNumber min={0} max={100} precision={2} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -790,7 +740,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("jobs.fields.materials.tax_ind")} label={t("jobs.fields.materials.tax_ind")}
@@ -817,7 +767,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -875,7 +825,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} suffix="%" /> <InputNumber min={-100} max={100} precision={4} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("jobs.fields.materials.tax_ind")} label={t("jobs.fields.materials.tax_ind")}
@@ -902,7 +852,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
} }
]} ]}
> >
<InputNumber min={0} max={100} precision={4} suffix="%" /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
); );
}} }}
@@ -943,15 +893,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")}
@@ -2195,74 +2145,76 @@ 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 getRootTaxFormItems({ typeNum, bodyshop, t }) { function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
return [ const { t } = useTranslation();
<Form.Item
key={`tax_type_${typeNum}_type`} if (rootElements)
label={t("bodyshop.fields.responsibilitycenter_tax_type", { typeNum })} return (
rules={[ <>
{ <Form.Item
required: true label={t("bodyshop.fields.responsibilitycenter_tax_type", {
//message: t("general.validation.required"), typeNum,
} typeNumIterator
]} })}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]} rules={[
> {
<Input /> required: true
</Form.Item>, //message: t("general.validation.required"),
<Form.Item }
key={`tax_type_${typeNum}_name`} ]}
label={t("bodyshop.fields.responsibilitycenters.state_tax")} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
rules={[ >
{ <Input />
required: true </Form.Item>
//message: t("general.validation.required"),
} <Form.Item
]} label={t("bodyshop.fields.responsibilitycenters.state_tax")}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]} rules={[
> {
<Input /> required: true
</Form.Item>, //message: t("general.validation.required"),
<Form.Item }
key={`tax_type_${typeNum}_accountdesc`} ]}
label={t("bodyshop.fields.responsibilitycenter_accountdesc")} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
rules={[ >
{ <Input />
required: true </Form.Item>
//message: t("general.validation.required"),
} <Form.Item
]} label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]} rules={[
> {
<Input /> required: true
</Form.Item>, //message: t("general.validation.required"),
<Form.Item }
key={`tax_type_${typeNum}_accountitem`} ]}
label={t("bodyshop.fields.responsibilitycenter_accountitem")} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
rules={[ >
{ <Input />
required: true </Form.Item>
//message: t("general.validation.required"), <Form.Item
} label={t("bodyshop.fields.responsibilitycenter_accountitem")}
]} rules={[
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]} {
> required: true
<Input /> //message: t("general.validation.required"),
</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={[
{ {
@@ -2274,64 +2226,71 @@ function getRootTaxFormItems({ typeNum, bodyshop, t }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
] )}
: []) </>
]; );
} return (
<>
function getTierTaxFormItems({ typeNum, typeNumIterator, t }) { <Form.Item
return [ label={t("bodyshop.fields.responsibilitycenter_tax_tier", {
<Form.Item typeNum,
key={`tax_type_${typeNum}_tier_${typeNumIterator}`} 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
key={`tax_type_${typeNum}_threshold_${typeNumIterator}`} label={t("bodyshop.fields.responsibilitycenter_tax_thres", {
label={t("bodyshop.labels.responsibilitycenters.tax_threshold_short")} typeNum,
rules={[ typeNumIterator
{ })}
required: true rules={[
//message: t("general.validation.required"), {
} required: true
]} //message: t("general.validation.required"),
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]} }
> ]}
<InputNumber min={0} precision={2} /> name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
</Form.Item>, >
<Form.Item <InputNumber min={0} precision={2} />
key={`tax_type_${typeNum}_rate_${typeNumIterator}`} </Form.Item>
label={t("bodyshop.labels.responsibilitycenters.tax_rate_short")} <Form.Item
rules={[ label={t("bodyshop.fields.responsibilitycenter_tax_rate", {
{ typeNum,
required: true typeNumIterator
//message: t("general.validation.required"), })}
} rules={[
]} {
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]} required: true
> //message: t("general.validation.required"),
<InputNumber min={0} precision={2} suffix="%" /> }
</Form.Item>, ]}
<Form.Item name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
key={`tax_type_${typeNum}_surcharge_${typeNumIterator}`} >
label={t("bodyshop.labels.responsibilitycenters.tax_surcharge_short")} <InputNumber min={0} precision={2} />
rules={[ </Form.Item>
{ <Form.Item
required: true label={t("bodyshop.fields.responsibilitycenter_tax_sur", {
//message: t("general.validation.required"), typeNum,
} typeNumIterator
]} })}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]} rules={[
> {
<InputNumber min={0} precision={2} suffix="%" /> required: true
</Form.Item> //message: t("general.validation.required"),
]; }
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
</>
);
} }

View File

@@ -1,25 +0,0 @@
.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%;
}
}

View File

@@ -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 header={t("bodyshop.labels.md_ro_guard_options")}> <LayoutFormRow noDivider>
<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} suffix="%" disabled={disabled} /> <InputNumber min={0} max={100} precision={1} disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item

View File

@@ -1,17 +1,10 @@
import { CloseOutlined, DeleteFilled, HolderOutlined } from "@ant-design/icons"; import { DeleteFilled } 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";
@@ -31,341 +24,10 @@ 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 }
@@ -375,119 +37,117 @@ 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">
<LayoutFormRow grow header={t("bodyshop.labels.job_status_options")}> <Form.Item
<div> name={["md_ro_statuses", "statuses"]}
<Form.Item label={t("bodyshop.labels.alljobstatuses")}
name={["md_ro_statuses", "statuses"]} rules={[
label={t("bodyshop.labels.alljobstatuses")} {
required required: true,
rules={[ //message: t("general.validation.required"),
{ type: "array"
validator: async (_, value) => { }
const populatedStatuses = normalizeStatuses(value); ]}
>
if (populatedStatuses.length === 0) { <Select mode="tags" onBlur={handleBlur} />
return Promise.reject( </Form.Item>
new Error( <Form.Item
t("general.validation.required", { name={["md_ro_statuses", "active_statuses"]}
label: t("bodyshop.labels.alljobstatuses") label={t("bodyshop.fields.statuses.active_statuses")}
}) 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"]}
<SortableStatusesSelect /> label={t("bodyshop.fields.statuses.pre_production_statuses")}
</Form.Item> rules={[
<Form.Item {
name={["md_ro_statuses", "active_statuses"]} required: true,
label={t("bodyshop.fields.statuses.active_statuses")} //message: t("general.validation.required"),
rules={[ type: "array"
{ }
required: true, ]}
//message: t("general.validation.required"), >
type: "array" <Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
} </Form.Item>
]} <Form.Item
> name={["md_ro_statuses", "production_statuses"]}
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} /> label={t("bodyshop.fields.statuses.production_statuses")}
</Form.Item> rules={[
<Form.Item {
name={["md_ro_statuses", "pre_production_statuses"]} required: true,
label={t("bodyshop.fields.statuses.pre_production_statuses")} //message: t("general.validation.required"),
rules={[ type: "array"
{ }
required: true, ]}
//message: t("general.validation.required"), >
type: "array" <Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
} </Form.Item>
]} <Form.Item
> name={["md_ro_statuses", "post_production_statuses"]}
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} /> label={t("bodyshop.fields.statuses.post_production_statuses")}
</Form.Item> rules={[
<Form.Item {
name={["md_ro_statuses", "production_statuses"]} required: true,
label={t("bodyshop.fields.statuses.production_statuses")} //message: t("general.validation.required"),
rules={[ type: "array"
{ }
required: true, ]}
//message: t("general.validation.required"), >
type: "array" <Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
} </Form.Item>
]} <Form.Item
> name={["md_ro_statuses", "ready_statuses"]}
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} /> label={t("bodyshop.fields.statuses.ready_statuses")}
</Form.Item> rules={[
<Form.Item {
name={["md_ro_statuses", "post_production_statuses"]} //required: true,
label={t("bodyshop.fields.statuses.post_production_statuses")} //message: t("general.validation.required"),
rules={[ type: "array"
{ }
required: true, ]}
//message: t("general.validation.required"), >
type: "array" <Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
} </Form.Item>
]} <Form.Item
> name={["md_ro_statuses", "additional_board_statuses"]}
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} /> label={t("bodyshop.fields.statuses.additional_board_statuses")}
</Form.Item> rules={[
<Form.Item {
name={["md_ro_statuses", "ready_statuses"]} //required: true,
label={t("bodyshop.fields.statuses.ready_statuses")} //message: t("general.validation.required"),
rules={[ type: "array"
{ }
//required: true, ]}
//message: t("general.validation.required"), >
type: "array" <Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
} </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={[
@@ -498,7 +158,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_scheduled"]} name={["md_ro_statuses", "default_scheduled"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_arrived")} label={t("bodyshop.fields.statuses.default_arrived")}
@@ -510,7 +170,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_arrived"]} name={["md_ro_statuses", "default_arrived"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_exported")} label={t("bodyshop.fields.statuses.default_exported")}
@@ -522,7 +182,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_exported"]} name={["md_ro_statuses", "default_exported"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_imported")} label={t("bodyshop.fields.statuses.default_imported")}
@@ -534,7 +194,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_imported"]} name={["md_ro_statuses", "default_imported"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_invoiced")} label={t("bodyshop.fields.statuses.default_invoiced")}
@@ -546,7 +206,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_invoiced"]} name={["md_ro_statuses", "default_invoiced"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_completed")} label={t("bodyshop.fields.statuses.default_completed")}
@@ -558,7 +218,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_completed"]} name={["md_ro_statuses", "default_completed"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_delivered")} label={t("bodyshop.fields.statuses.default_delivered")}
@@ -570,7 +230,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_delivered"]} name={["md_ro_statuses", "default_delivered"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_void")} label={t("bodyshop.fields.statuses.default_void")}
@@ -582,122 +242,73 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_void"]} name={["md_ro_statuses", "default_void"]}
> >
<Select options={statusSelectOptions} /> <Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
{Production_List_Status_Colors.treatment === "on" && ( {Production_List_Status_Colors.treatment === "on" && (
<Form.List name={["md_ro_statuses", "production_colors"]}> <LayoutFormRow grow header={t("bodyshop.fields.statuses.production_colors")} id="production_colors">
{(fields, { add, remove }) => { <Form.List name={["md_ro_statuses", "production_colors"]}>
return ( {(fields, { add, remove }) => {
<LayoutFormRow return (
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>
{fields.length === 0 ? ( <Space size="large" wrap>
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_production_status_color")} /> {fields.map((field, index) => (
) : ( <Form.Item key={field.key}>
<Space size="large" wrap align="start"> <Space orientation="vertical">
{fields.map((field, index) => { <div style={{ display: "flex" }}>
const productionColor = productionColors[field.name] || {}; <Form.Item
const productionColorSurfaceStyles = getTintedCardSurfaceStyles(productionColor.color); style={{ flex: 1 }}
const selectedProductionColorStatuses = productionColors label={t("jobs.fields.status")}
.map((item) => item?.status) key={`${index}status`}
.filter(Boolean); name={[field.name, "status"]}
const productionColorStatusOptions = [ rules={[
...new Set([productionColor.status, ...availableProductionStatuses]) {
] required: true
.filter(Boolean) //message: t("general.validation.required"),
.filter( }
(status) => ]}
status === productionColor.status || !selectedProductionColorStatuses.includes(status) >
); <Select options={productionStatus.map((item) => ({ value: item, label: item }))} />
</Form.Item>
return ( <DeleteFilled
<InlineValidatedFormRow onClick={() => {
form={form} remove(field.name);
errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]} }}
key={field.key} />
noDivider </div>
title={ <Form.Item
<Form.Item label={t("bodyshop.fields.statuses.color")}
noStyle key={`${index}color`}
key={`${index}status`} name={[field.name, "color"]}
name={[field.name, "status"]} rules={[
rules={[ {
{ required: true
required: true //message: t("general.validation.required"),
//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 }}
> >
<div> <ColorPicker />
<Form.Item </Form.Item>
key={`${index}color`} </Space>
name={[field.name, "color"]} </Form.Item>
rules={[ ))}
{ </Space>
required: true <Form.Item>
//message: t("general.validation.required"), <Button
} type="dashed"
]} onClick={() => {
> add();
<ColorPicker /> }}
</Form.Item> style={{ width: "100%" }}
</div> >
</InlineValidatedFormRow> {t("general.actions.add")}
); </Button>
})} </Form.Item>
</Space>
)}
</div> </div>
</LayoutFormRow> );
); }}
}} </Form.List>
</Form.List> </LayoutFormRow>
)} )}
</SelectorDiv> </SelectorDiv>
); );

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, ReloadOutlined } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch, TimePicker, Tooltip } from "antd"; import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } 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,16 +7,8 @@ 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
@@ -25,514 +17,301 @@ 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 grow header={t("bodyshop.labels.scheduling")} id="shopinfo-scheduling"> <LayoutFormRow id="shopinfo-scheduling">
<> <Form.Item
<Form.Item label={t("bodyshop.fields.appt_length")}
name={["appt_alt_transport"]} name={"appt_length"}
label={t("bodyshop.fields.appt_alt_transport")} rules={[
rules={[ {
{ required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
type: "array" }
} ]}
]} >
> <InputNumber min={15} precision={0} />
<Select mode="tags" /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("bodyshop.fields.schedule_start_time")}
name={["md_lost_sale_reasons"]} name={"schedule_start_time"}
label={t("bodyshop.fields.md_lost_sale_reasons")} rules={[
rules={[ {
{ required: true
// required: true, //message: t("general.validation.required"),
//message: t("general.validation.required"), }
type: "array" ]}
} id="schedule_start_time"
]} >
> <TimePicker disableSeconds={true} format="HH:mm" />
<Select mode="tags" /> </Form.Item>
</Form.Item> <Form.Item
<Row gutter={[16, 0]} wrap> label={t("bodyshop.fields.schedule_end_time")}
<Col xs={24} sm={12} xl={6}> name={"schedule_end_time"}
<Form.Item rules={[
label={t("bodyshop.fields.appt_length")} {
name={"appt_length"} required: true
rules={[ //message: t("general.validation.required"),
{ }
required: true ]}
//message: t("general.validation.required"), id="schedule_end_time"
} >
]} <TimePicker disableSeconds={true} format="HH:mm" />
> </Form.Item>
<InputNumber min={15} precision={0} suffix="min" /> <Form.Item
</Form.Item> name={["appt_alt_transport"]}
</Col> label={t("bodyshop.fields.appt_alt_transport")}
<Col xs={24} sm={12} xl={6}> rules={[
<Form.Item {
label={t("bodyshop.fields.schedule_start_time")} //message: t("general.validation.required"),
name={"schedule_start_time"} type: "array"
rules={[ }
{ ]}
required: true >
//message: t("general.validation.required"), <Select mode="tags" />
} </Form.Item>
]} <Form.Item
id="schedule_start_time" name={["ss_configuration", "dailyhrslimit"]}
> label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
<TimePicker disableSeconds={true} format="HH:mm" /> >
</Form.Item> <InputNumber min={0} />
</Col> </Form.Item>
<Col xs={24} sm={12} xl={6}> <Form.Item
<Form.Item name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.schedule_end_time")} label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
name={"schedule_end_time"} valuePropName="checked"
rules={[ >
{ <Switch />
required: true </Form.Item>
//message: t("general.validation.required"), <Form.Item
} name={["md_lost_sale_reasons"]}
]} label={t("bodyshop.fields.md_lost_sale_reasons")}
id="schedule_end_time" rules={[
> {
<TimePicker disableSeconds={true} format="HH:mm" /> // required: true,
</Form.Item> //message: t("general.validation.required"),
</Col> type: "array"
<Col xs={24} sm={12} xl={6}> }
<Form.Item ]}
name={["ss_configuration", "dailyhrslimit"]} >
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")} <Select mode="tags" />
> </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>
<LayoutFormRow header={t("bodyshop.labels.workingdays")} id="workingdays"> <Divider titlePlacement="left">{t("bodyshop.labels.workingdays")}</Divider>
<Space wrap size="middle"> <Space wrap size="large" id="workingdays">
{WORKING_DAYS.map(({ key, labelKey }) => ( <Form.Item label={t("general.labels.sunday")} name={["workingdays", "sunday"]} valuePropName="checked">
<Form.Item key={key} label={t(labelKey)} name={["workingdays", key]} valuePropName="checked"> <Switch />
<Switch /> </Form.Item>
</Form.Item> <Form.Item label={t("general.labels.monday")} name={["workingdays", "monday"]} valuePropName="checked">
))} <Switch />
</Space> </Form.Item>
</LayoutFormRow> <Form.Item label={t("general.labels.tuesday")} name={["workingdays", "tuesday"]} valuePropName="checked">
<Form.List name={["appt_colors"]}> <Switch />
{(fields, { add, remove, move }) => { </Form.Item>
return ( <Form.Item label={t("general.labels.wednesday")} name={["workingdays", "wednesday"]} valuePropName="checked">
<LayoutFormRow <Switch />
header={t("bodyshop.labels.apptcolors")} </Form.Item>
id="apptcolors" <Form.Item label={t("general.labels.thursday")} name={["workingdays", "thursday"]} valuePropName="checked">
actions={[ <Switch />
<Button </Form.Item>
key="add-appointment-color" <Form.Item label={t("general.labels.friday")} name={["workingdays", "friday"]} valuePropName="checked">
type="primary" <Switch />
block </Form.Item>
onClick={() => { <Form.Item label={t("general.labels.saturday")} name={["workingdays", "saturday"]} valuePropName="checked">
add({ <Switch />
color: { </Form.Item>
...DEFAULT_TRANSLUCENT_PICKER_COLOR, </Space>
rgb: { ...DEFAULT_TRANSLUCENT_PICKER_COLOR.rgb } <LayoutFormRow header={t("bodyshop.labels.apptcolors")} id="apptcolors">
} <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 (
<LayoutFormRow <div>
header={t("bodyshop.labels.ssbuckets")} {fields.map((field, index) => (
id="ssbuckets" <Form.Item key={field.key}>
actions={[ <LayoutFormRow noDivider>
<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
key="add-job-size-definition" type="dashed"
type="primary"
block
onClick={() => { onClick={() => {
add({ add();
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
});
}} }}
style={{ width: "100%" }}
> >
{t("bodyshop.actions.addbucket")} {t("bodyshop.actions.addapptcolor")}
</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>
); );

View File

@@ -1,58 +0,0 @@
.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);
}
}

View File

@@ -1,213 +0,0 @@
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
);
});
}

View File

@@ -1,55 +0,0 @@
.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));
}
}

View File

@@ -3,23 +3,11 @@ 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)),
@@ -30,131 +18,80 @@ export default function ShopInfoSpeedPrint() {
<Form.List name={["speedprint"]}> <Form.List name={["speedprint"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<LayoutFormRow <div>
header={t("bodyshop.labels.speedprint_configurations")} {fields.map((field, index) => (
actions={[ <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<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
key="add-speedprint" type="dashed"
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>

View File

@@ -2,8 +2,6 @@ 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";
@@ -57,12 +55,10 @@ const getTaskPresetAllocationErrors = (presets = [], t) => {
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 header={t("bodyshop.labels.task_preset_options")}> <LayoutFormRow noDivider>
<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"
@@ -79,216 +75,187 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.List <LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
name={["md_tasks_presets", "presets"]} <Form.List
rules={[ name={["md_tasks_presets", "presets"]}
{ rules={[
validator: async (_, presets) => { {
const allocationErrors = getTaskPresetAllocationErrors(presets, t); validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) { if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" ")); throw new Error(allocationErrors.join(" "));
}
} }
} }
} ]}
]} >
> {(fields, { add, remove, move }, { errors }) => {
{(fields, { add, remove, move }, { errors }) => { return (
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.length === 0 ? ( {fields.map((field, index) => (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_task_preset")} /> <Form.Item key={field.key}>
) : ( <LayoutFormRow noDivider>
fields.map((field, index) => { <Form.Item
const taskPreset = taskPresets[field.name] || {}; label={t("bodyshop.fields.md_tasks_presets.name")}
key={`${index}name`}
return ( name={[field.name, "name"]}
<Form.Item key={field.key}> rules={[
<LayoutFormRow {
noDivider required: true
title={getFormListItemTitle( //message: t("general.validation.required"),
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 >
label={t("bodyshop.fields.md_tasks_presets.name")} <Input />
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`}
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.ErrorList errors={errors} /> <Form.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.add_task_preset")}
</Button>
</Form.Item>
</div> </div>
</LayoutFormRow> );
); }}
}} </Form.List>
</Form.List> </LayoutFormRow>
</> </>
); );
} }

View File

@@ -5,7 +5,6 @@ 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
@@ -18,22 +17,19 @@ 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 (
<> <>
{cashDiscountEnabled && ( <Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
<div style={{ marginBottom: 12 }}> {() => {
<Alert title={t("bodyshop.labels.intellipay_cash_discount")} /> const { intellipay_config } = form.getFieldsValue();
</div>
)}
<LayoutFormRow if (intellipay_config?.enable_cash_discount)
header={InstanceRenderManager({ return <Alert title={t("bodyshop.labels.intellipay_cash_discount")} />;
rome: t("bodyshop.labels.romepay"), }}
imex: t("bodyshop.labels.imexpay") </Form.Item>
})}
> <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"

View File

@@ -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, Col, Form, Input, InputNumber, Row, Select, Skeleton, Space, Switch, Typography } from "antd"; import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography } from "antd";
import querystring from "query-string"; import querystring from "query-string";
import { useCallback, useEffect, useState } from "react"; import { useEffect } 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,22 +11,9 @@ 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,
@@ -35,26 +22,54 @@ 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 = () => ({});
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [ const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" }, { labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" } { labelKey: "employee_teams.options.commission_percentage", value: "commission" }
]; ];
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
employee: { xs: 24, lg: 13, xxl: 14 },
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
};
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 }; const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
});
const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null;
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
return fullName || employee.employee_number || null;
};
const formatAllocationPercentage = (percentage) => { const formatAllocationPercentage = (percentage) => {
if (percentage === null || percentage === undefined || percentage === "") return null; if (percentage === null || percentage === undefined || percentage === "") return null;
@@ -64,88 +79,27 @@ const formatAllocationPercentage = (percentage) => {
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`; return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
}; };
export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [internalForm] = Form.useForm(); const [form] = 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, loading, refetch } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, { const { error, data } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId }, variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || isNewTeam, skip: !search.employeeTeamId || search.employeeTeamId === "new",
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only"
notifyOnNetworkStatusChange: true
}); });
const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null; useEffect(() => {
if (data?.employee_teams_by_pk) {
const updateDirtyState = useCallback( form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
(nextDirtyState) => { } else {
setInternalIsDirty(nextDirtyState); form.resetFields();
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);
@@ -153,32 +107,59 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange,
label: t(labelKey), label: t(labelKey),
value value
})); }));
const teamName = Form.useWatch("name", teamForm); const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || []; const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId; const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name");
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0"; const getTeamMemberTitle = (teamMember = {}) => {
const teamNameDisplay = teamName?.trim() || t("employee_teams.fields.name"); const employeeName =
const teamCardTitle = isTeamHydrating ? ( getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
t("employee_teams.fields.name") const allocation = formatAllocationPercentage(teamMember.percentage);
) : ( const payoutMethod =
<span> teamMember.payout_method === "commission"
<span>{teamNameDisplay}</span> ? t("employee_teams.options.commission")
<span> - </span> : t("employee_teams.options.hourly");
<Typography.Text type={isAllocationTotalExact ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", { return (
total: allocationTotalValue <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
})} <Typography.Text strong>{employeeName}</Typography.Text>
</Typography.Text> <Tag bordered={false} color="geekblue">
</span> {`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
); </Tag>
<Tag bordered={false} color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod}
</Tag>
</div>
);
};
const handleFinish = async ({ employee_team_members = [], ...values }) => { const handleFinish = async ({ employee_team_members = [], ...values }) => {
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members); const normalizedTeamMembers = employee_team_members.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
if (errorKey) { if (normalizedTeamMembers.length === 0) {
notification.error({ notification.error({
title: t(errorKey) title: t("employee_teams.errors.minimum_one_member")
});
return;
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
notification.error({
title: t("employee_teams.errors.duplicate_member")
});
return;
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
notification.error({
title: t("employee_teams.errors.allocation_total_exact")
}); });
return; return;
} }
@@ -208,8 +189,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange,
}); });
if (!result.errors) { if (!result.errors) {
updateDirtyState(false);
void refetch();
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
}); });
@@ -233,7 +212,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange,
}, },
refetchQueries: ["QUERY_TEAMS"] refetchQueries: ["QUERY_TEAMS"]
}).then((response) => { }).then((response) => {
updateDirtyState(false);
search.employeeTeamId = response.data.insert_employee_teams_one.id; search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) }); history({ search: querystring.stringify(search) });
notification.success({ notification.success({
@@ -248,272 +226,193 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange,
return ( return (
<Card <Card
title={isTeamHydrating ? undefined : teamCardTitle} title={teamCardTitle}
extra={ extra={
<Button <Button type="primary" onClick={() => form.submit()}>
type="primary" {t("general.actions.save")}
onClick={() => teamForm.submit()}
disabled={isTeamHydrating || !resolvedIsDirty}
style={{ minWidth: 190 }}
>
{t("employee_teams.actions.save_team")}
</Button> </Button>
} }
> >
{isTeamHydrating ? ( <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Skeleton active title={false} paragraph={{ rows: 12 }} /> <LayoutFormRow>
) : ( <Form.Item
<Form name="name"
onFinish={handleFinish} label={t("employee_teams.fields.name")}
autoComplete={"off"} rules={[
layout="vertical" {
form={teamForm} required: true
onValuesChange={() => { }
updateDirtyState(teamForm.isFieldsTouched()); ]}
}}
>
<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 <Input />
name="name" </Form.Item>
label={t("employee_teams.fields.name")} <Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
rules={[ <Switch />
{ </Form.Item>
required: true <Form.Item
} label={t("employee_teams.fields.max_load")}
]} name="max_load"
> rules={[
<Input /> {
</Form.Item> required: true
<Form.Item }
label={t("employee_teams.fields.max_load")} ]}
name="max_load" >
rules={[ <InputNumber min={0} precision={1} />
{ </Form.Item>
required: true </LayoutFormRow>
} <Form.List name={["employee_team_members"]}>
]} {(fields, { add, remove, move }) => {
> return (
<InputNumber min={0} precision={1} suffix="%" /> <div>
</Form.Item> {fields.map((field, index) => {
</LayoutFormRow> const teamMember = normalizeTeamMember(teamMembers[field.name]);
<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 ( return (
<Row gutter={[16, 0]}> <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
{LABOR_TYPES.map((laborType) => ( <Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
<Col <Input type="hidden" />
{...TEAM_MEMBER_RATE_FIELD_COLS} </Form.Item>
key={`${index}-${fieldName}-${laborType}`} <LayoutFormRow
> grow
<Form.Item title={getTeamMemberTitle(teamMember)}
label={t(`joblines.fields.lbr_types.${laborType}`)} extra={
name={[field.name, fieldName, laborType]} <Space align="center" size="small">
rules={[ <Button
{ type="text"
required: true icon={<DeleteFilled />}
} onClick={() => {
]} remove(field.name);
> }}
{payoutMethod === "commission" ? ( />
<InputNumber min={0} max={100} precision={2} suffix="%" /> <FormListMoveArrows
) : ( move={move}
<CurrencyInput prefix="$" /> index={index}
)} total={fields.length}
</Form.Item> orientation="horizontal"
</Col> />
))} </Space>
</Row> }
); >
}} <div>
</Form.Item> <Row gutter={[16, 0]}>
</div> <Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
</InlineValidatedFormRow> <Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
<Form.Item
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
<Form.Item
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select options={payoutMethodOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
<Form.Item
label={
t(`joblines.fields.lbr_types.${laborType}`)
}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item> </Form.Item>
); </div>
}) </LayoutFormRow>
)} </Form.Item>
</div> );
</LayoutFormRow> })}
); <Form.Item>
}} <Button
</Form.List> type="dashed"
</Form> onClick={() => {
)} add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
</Form.Item>
</div>
);
}}
</Form.List>
</Form>
</Card> </Card>
); );
} }

View File

@@ -1,254 +0,0 @@
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"
});
});
});

View File

@@ -1,70 +0,0 @@
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
};
};

View File

@@ -1,86 +0,0 @@
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 }
}
]);
});
});

View File

@@ -2,47 +2,20 @@ 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({ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
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) {
navigateToTeam(record.id); search.employeeTeamId = record.id;
history({ search: queryString.stringify(search) });
} else { } else {
clearTeamSelection(); delete search.employeeTeamId;
history({ search: queryString.stringify(search) });
} }
}; };
const columns = [ const columns = [
@@ -54,38 +27,43 @@ export default function ShopEmployeeTeamsListComponent({
]; ];
return ( return (
<LayoutFormRow <div>
title={t("bodyshop.labels.employee_teams")} <ResponsiveTable
actions={[ title={() => {
<Button key="new-team" type="primary" block onClick={() => navigateToTeam("new")}> return (
{t("employee_teams.actions.new")} <Button
</Button> type="primary"
]} onClick={() => {
> search.employeeTeamId = "new";
{employee_teams.length === 0 ? ( history({ search: queryString.stringify(search) });
<ConfigListEmptyState actionLabel={t("employee_teams.actions.new")} /> }}
) : ( >
<ResponsiveTable {t("employee_teams.actions.new")}
loading={loading} </Button>
pagination={{ placement: "top" }} );
columns={columns} }}
mobileColumnKeys={["name"]} loading={loading}
rowKey="id" pagination={{ placement: "top" }}
dataSource={employee_teams} columns={columns}
rowSelection={{ mobileColumnKeys={["name"]}
onSelect: (props) => navigateToTeam(props.id), rowKey="id"
type: "radio", dataSource={employee_teams}
selectedRowKeys: [selectedTeamId || search.employeeTeamId] rowSelection={{
}} onSelect: (props) => {
onRow={(record) => { search.employeeTeamId = props.id;
return { history({ search: queryString.stringify(search) });
onClick: () => { },
handleOnRowClick(record); type: "radio",
} selectedRowKeys: [search.employeeTeamId]
}; }}
}} onRow={(record) => {
/> return {
)} onClick: () => {
</LayoutFormRow> handleOnRowClick(record);
}
};
}}
/>
</div>
); );
} }

View File

@@ -1,70 +1,36 @@
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 "./shop-teams.styles.scss"; import { Col, Row } from "antd";
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 (
<RbacWrapper action="employee_teams:page"> <div>
<div <RbacWrapper action="employee_teams:page">
className={["shop-teams-layout", hasSelectedTeam ? "shop-teams-layout--with-detail" : null] <Row gutter={[16, 16]}>
.filter(Boolean) <Col span={6}>
.join(" ")} <ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} />
> </Col>
<div className="shop-teams-layout__list"> <Col span={18}>
<ShopEmployeeTeamsListComponent <ShopEmployeeTeamsFormComponent />
employee_teams={data ? data.employee_teams : []} </Col>
loading={loading} </Row>
onRequestTeamChange={navigateToTeam} </RbacWrapper>
selectedTeamId={search.employeeTeamId} </div>
/>
</div>
{hasSelectedTeam ? (
<div className="shop-teams-layout__details">
<ShopEmployeeTeamsFormComponent form={form} onDirtyChange={setIsTeamFormDirty} isDirty={isTeamFormDirty} />
</div>
) : null}
</div>
</RbacWrapper>
); );
} }

View File

@@ -1,16 +0,0 @@
.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);
}
}

View File

@@ -6,7 +6,6 @@ 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";
@@ -67,7 +66,7 @@ export function ShopInfoUsersComponent({ bodyshop }) {
return <AlertComponent type="error" title={JSON.stringify(error)} />; return <AlertComponent type="error" title={JSON.stringify(error)} />;
} }
return ( return (
<LayoutFormRow title={t("bodyshop.labels.licensing")}> <div>
<ResponsiveTable <ResponsiveTable
loading={loading} loading={loading}
pagination={{ placement: "top" }} pagination={{ placement: "top" }}
@@ -76,6 +75,6 @@ export function ShopInfoUsersComponent({ bodyshop }) {
rowKey="id" rowKey="id"
dataSource={data && data.associations} dataSource={data && data.associations}
/> />
</LayoutFormRow> </div>
); );
} }

View File

@@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd"; import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState, useRef } from "react"; import { 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 { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -15,27 +15,21 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component"; import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket, timeTicketModal: selectTimeTicket,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")), toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket"))
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
}); });
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) { export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false); const [enterAgain, setEnterAgain] = useState(false);
const lastSubmittedRef = useRef(null);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0); const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET); const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
@@ -54,77 +48,47 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => { const handleFinish = (values) => {
lastSubmittedRef.current = values;
setLoading(true); setLoading(true);
const isEdit = Boolean(timeTicketModal.context.id); const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
const emps = employees.filter((employee) => employee.id === values.employeeid); if (timeTicketModal.context.id) {
const mutation = isEdit updateTicket({
? updateTicket({ variables: {
variables: { timeticketId: timeTicketModal.context.id,
timeticketId: timeTicketModal.context.id, timeticket: {
timeticket: { ...values,
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
...values, ...values,
rate: rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
} }
} ]
}) }
: insertTicket({ })
variables: { .then(handleMutationSuccess)
timeTicketInput: [ .catch(handleMutationError);
{ }
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
}; };
const handleMutationSuccess = (result, isEdit) => { const handleMutationSuccess = () => {
notification.success({ notification.success({
title: t("timetickets.successes.created") title: t("timetickets.successes.created")
}); });
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
// Refresh parent screens (Job Labor tab, etc.) // Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch(); if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

@@ -1,117 +0,0 @@
import { render, screen } from "@testing-library/react";
import { Form } from "antd";
import { describe, expect, it, vi } from "vitest";
import { TimeTicketTaskModalComponent } from "./time-ticket-task-modal.component.jsx";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"timetickets.fields.ro_number": "RO Number",
"timetickets.labels.task": "Task",
"bodyshop.fields.md_tasks_presets.percent": "Percent",
"bodyshop.fields.md_tasks_presets.hourstype": "Labor Types",
"bodyshop.fields.md_tasks_presets.nextstatus": "Next Status",
"timetickets.labels.claimtaskpreview": "Claim Task Preview",
"timetickets.fields.employee": "Employee",
"timetickets.fields.cost_center": "Cost Center",
"timetickets.fields.ciecacode": "Labor Type",
"timetickets.fields.productivehrs": "Hours",
"timetickets.fields.payout_method": "Payout Method",
"timetickets.fields.rate": "Rate",
"timetickets.fields.amount": "Amount",
"timetickets.labels.payout_methods.commission": "Commission",
"timetickets.labels.payout_methods.hourly": "Hourly",
"timetickets.labels.payrollclaimedtasks": "Payroll claimed tasks are ready.",
"tt_approvals.labels.approval_queue_in_use": "Approval queue is enabled."
};
if (key === "timetickets.validation.unassignedlines") {
return `${values.unassignedHours} hours remain unassigned.`;
}
return translations[key] || key;
}
})
}));
vi.mock("../form-items-formatted/read-only-form-item.component", () => ({
default: ({ value }) => <span>{value}</span>
}));
vi.mock("../job-search-select/job-search-select.component", () => ({
default: () => <div>Job Search</div>
}));
function TestHarness({ unassignedHours = 0 }) {
const [form] = Form.useForm();
return (
<Form
form={form}
initialValues={{
task: "Body Prep",
timetickets: [
{
employeeid: "emp-1",
cost_center: "Body",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payoutamount: 80,
payout_context: {
payout_method: "commission"
}
},
{
employeeid: "emp-2",
cost_center: "Refinish",
ciecacode: "LAB",
productivehrs: 1,
rate: 28,
payoutamount: 28,
payout_context: {
payout_method: "hourly"
}
}
]
}}
>
<TimeTicketTaskModalComponent
bodyshop={{
md_tasks_presets: {
presets: [
{
name: "Body Prep",
percent: 50,
hourstype: ["LAA", "LAB"],
nextstatus: "In Progress"
}
]
}
}}
form={form}
loading={false}
completedTasks={[]}
unassignedHours={unassignedHours}
/>
</Form>
);
}
describe("TimeTicketTaskModalComponent", () => {
it("shows preview payout methods for both commission and hourly tickets", () => {
render(<TestHarness />);
expect(screen.getByText("Claim Task Preview")).toBeInTheDocument();
expect(screen.getByText("Commission")).toBeInTheDocument();
expect(screen.getByText("Hourly")).toBeInTheDocument();
expect(screen.getByText("Payroll claimed tasks are ready.")).toBeInTheDocument();
});
it("shows the unassigned-hours alert when payroll assignments are incomplete", () => {
render(<TestHarness unassignedHours={1.25} />);
expect(screen.getByText("1.25 hours remain unassigned.")).toBeInTheDocument();
});
});

View File

@@ -152,7 +152,7 @@ export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete
{!isPartsEntry && ( {!isPartsEntry && (
<> <>
<Form.Item label={t("vendors.fields.discount")} name="discount"> <Form.Item label={t("vendors.fields.discount")} name="discount">
<InputNumber min={0} max={1} precision={3} step={0.01} /> <InputNumber min={0} max={1} precision={2} step={0.01} />
</Form.Item> </Form.Item>
<Form.Item label={t("vendors.fields.due_date")} name="due_date"> <Form.Item label={t("vendors.fields.due_date")} name="due_date">

View File

@@ -197,7 +197,6 @@ export const QUERY_EXACT_JOB_IN_PRODUCTION = gql`
employee_prep employee_prep
employee_csr employee_csr
date_repairstarted date_repairstarted
dms_id
joblines_status { joblines_status {
part_type part_type
status status
@@ -270,7 +269,6 @@ export const QUERY_EXACT_JOBS_IN_PRODUCTION = gql`
employee_prep employee_prep
employee_csr employee_csr
date_repairstarted date_repairstarted
dms_id
joblines_status { joblines_status {
part_type part_type
status status
@@ -2673,7 +2671,6 @@ export const QUERY_JOBS_IN_PRODUCTION = gql`
suspended suspended
job_totals job_totals
date_repairstarted date_repairstarted
dms_id
joblines_status { joblines_status {
part_type part_type
status status

View File

@@ -1,11 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
export default function useConfirmDirtyFormNavigation(isDirty) {
const { t } = useTranslation();
return useCallback(() => {
if (!isDirty) return true;
return window.confirm(t("general.messages.unsavedchangespopup"));
}, [isDirty, t]);
}

View File

@@ -8,14 +8,13 @@ import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries"; import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries"; import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component"; import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context"; import JobCreateContext from "./jobs-create.context";
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";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -23,11 +22,10 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
}); });
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) { function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
const { t } = useTranslation(); const { t } = useTranslation();
const notification = useNotification(); const notification = useNotification();
@@ -86,11 +84,6 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id newJobId: resp.data.insert_jobs.returning[0].id
}); });
logImEXEvent("manual_job_create_completed", {}); logImEXEvent("manual_job_create_completed", {});
insertAuditTrail({
jobid: resp.data.insert_jobs.returning[0].id,
operation: AuditTrailMapping.jobmanualcreate(),
type: "jobmanualcreate"
});
setIsSubmitting(false); setIsSubmitting(false);
}) })
.catch((error) => { .catch((error) => {

View File

@@ -27,19 +27,12 @@ const mapDispatchToProps = (dispatch) => ({
}); });
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) { export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
const technicianId = technician?.id;
const teamIds = (bodyshop?.employee_teams || [])
.filter((employeeTeam) =>
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
)
.map((employeeTeam) => employeeTeam.id)
.filter(Boolean);
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, { const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
variables: { variables: {
teamIds teamIds: bodyshop.employee_teams
}, .filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
skip: !technicianId || !hasAssignedTeams .map((et) => et.id)
}
}); });
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
@@ -184,7 +177,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
<Card <Card
extra={ extra={
<Space wrap> <Space wrap>
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} /> <Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search <Input.Search
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
onChange={(e) => { onChange={(e) => {

View File

@@ -1,6 +1,6 @@
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { persistReducer } from "redux-persist"; import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage"; import storageModule from "redux-persist/lib/storage";
import { withReduxStateSync } from "redux-state-sync"; import { withReduxStateSync } from "redux-state-sync";
import applicationReducer from "./application/application.reducer"; import applicationReducer from "./application/application.reducer";
import emailReducer from "./email/email.reducer"; import emailReducer from "./email/email.reducer";
@@ -11,6 +11,8 @@ import techReducer from "./tech/tech.reducer";
import userReducer from "./user/user.reducer"; import userReducer from "./user/user.reducer";
import trelloReducer from "./trello/trello.reducer"; import trelloReducer from "./trello/trello.reducer";
const storage = storageModule?.default ?? storageModule;
// const persistConfig = { // const persistConfig = {
// key: "root", // key: "root",
// storage, // storage,

View File

@@ -120,9 +120,8 @@
"appointmentinsert": "Appointment created. Appointment Date: {{start}}.", "appointmentinsert": "Appointment created. Appointment Date: {{start}}.",
"assignedlinehours": "Assigned job lines totaling {{hours}} units to {{team}}.", "assignedlinehours": "Assigned job lines totaling {{hours}} units to {{team}}.",
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.", "billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
"billposted": "Bill with invoice number {{invoice_number}} posted.", "billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.", "billupdated": "Bill with invoice number {{invoice_number}} updated.",
"failedpayment": "Failed payment attempt.", "failedpayment": "Failed payment attempt.",
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}", "jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
"jobassignmentremoved": "Employee assignment removed for {{operation}}", "jobassignmentremoved": "Employee assignment removed for {{operation}}",
@@ -137,9 +136,6 @@
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.", "jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
"jobinvoiced": "Job has been invoiced.", "jobinvoiced": "Job has been invoiced.",
"jobioucreated": "IOU Created.", "jobioucreated": "IOU Created.",
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
"jobmanualcreate": "Job manually created.",
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.", "jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
"jobnoteadded": "Note added to Job.", "jobnoteadded": "Note added to Job.",
"jobnotedeleted": "Note deleted from Job.", "jobnotedeleted": "Note deleted from Job.",
@@ -155,9 +151,7 @@
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}", "tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}", "tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}", "tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}", "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
} }
}, },
"billlines": { "billlines": {
@@ -237,16 +231,13 @@
"overall": "Overall" "overall": "Overall"
}, },
"disclaimer_title": "AI Scan Beta Disclaimer", "disclaimer_title": "AI Scan Beta Disclaimer",
"feedback_placeholder": "Tell us what worked, what didn't, and what could be better.",
"feedback_prompt": "Was this AI scan helpful?",
"generic_failure": "Failed to process invoice.", "generic_failure": "Failed to process invoice.",
"multipage": "The is a multi-page document. Processing will take a few moments.", "multipage": "The is a multi-page document. Processing will take a few moments.",
"processing": "Analyzing Bill", "processing": "Analyzing Bill",
"scan": "AI Bill Scanner", "scan": "AI Bill Scanner",
"scancomplete": "AI Scan Complete", "scancomplete": "AI Scan Complete",
"scanfailed": "AI Scan Failed", "scanfailed": "AI Scan Failed",
"scanstarted": "AI Scan Started", "scanstarted": "AI Scan Started"
"submit_feedback": "Submit Feedback"
}, },
"bill_lines": "Bill Lines", "bill_lines": "Bill Lines",
"bill_total": "Bill Total Amount", "bill_total": "Bill Total Amount",
@@ -298,23 +289,7 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "actions": {
"add_adjuster": "Add Adjuster",
"add_control_number": "Add Control Number",
"add_cost_center": "Add Cost Center",
"add_courtesy_car_rate_preset": "Add Courtesy Car Contract Rate Preset",
"add_delivery_checklist_item": "Add Delivery Checklist Item",
"add_dms_allocation": "Add DMS Allocation",
"add_estimator": "Add Estimator",
"add_insurance_company": "Add Insurance Company",
"add_intake_checklist_item": "Add Intake Checklist Item",
"add_jobline_preset": "Add Jobline Preset",
"add_messaging_preset": "Add Messaging Preset",
"add_note_preset": "Add Note Preset",
"add_parts_order_comment": "Add Parts Order Comment",
"add_production_status_color": "Add Production Status Color",
"add_profit_center": "Add Profit Center",
"add_task_preset": "Add Task Preset", "add_task_preset": "Add Task Preset",
"add_to_email_preset": "Add To Email Preset",
"addapptcolor": "Add Appointment Color", "addapptcolor": "Add Appointment Color",
"addbucket": "Add Definition", "addbucket": "Add Definition",
"addpartslocation": "Add Parts Location", "addpartslocation": "Add Parts Location",
@@ -323,13 +298,11 @@
"addtemplate": "Add Template", "addtemplate": "Add Template",
"newlaborrate": "New Labor Rate", "newlaborrate": "New Labor Rate",
"newsalestaxcode": "New Sales Tax Code", "newsalestaxcode": "New Sales Tax Code",
"save_shop_information": "Save Shop Information",
"newstatus": "Add Status", "newstatus": "Add Status",
"testrender": "Test Render" "testrender": "Test Render"
}, },
"errors": { "errors": {
"creatingdefaultview": "Error creating default view.", "creatingdefaultview": "Error creating default view.",
"duplicate_job_status": "Duplicate job status. Each job status must be unique.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique", "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.", "loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}", "saving": "Error encountered while saving. {{message}}",
@@ -363,7 +336,6 @@
"require_actual_delivery_date": "Require Actual Delivery", "require_actual_delivery_date": "Require Actual Delivery",
"templates": "Delivery Templates" "templates": "Delivery Templates"
}, },
"disableBillCostCalculation": "Disable Automatic Bill Cost Calculation",
"dms": { "dms": {
"apcontrol": "AP Control Number", "apcontrol": "AP Control Number",
"appostingaccount": "AP Posting Account", "appostingaccount": "AP Posting Account",
@@ -427,35 +399,6 @@
"logo_img_path": "Shop Logo", "logo_img_path": "Shop Logo",
"logo_img_path_height": "Logo Image Height", "logo_img_path_height": "Logo Image Height",
"logo_img_path_width": "Logo Image Width", "logo_img_path_width": "Logo Image Width",
"scoreboard_setup": {
"daily_body_target": "Daily Body Target",
"daily_paint_target": "Daily Paint Target",
"ignore_blocked_days": "Ignore Blocked Days",
"last_number_working_days": "Last Number of Working Days",
"production_target_hours": "Production Target Hours"
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "Attach PDF to Sent Emails?",
"from_emails": "Additional From Emails",
"parts_order_cc": "Parts Orders CC",
"parts_return_slip_cc": "Parts Returns CC"
},
"job_costing": {
"paint_hour_split": "Paint Hour Split",
"paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate",
"prep_hour_split": "Prep Hour Split",
"shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate",
"target_touch_time": "Target Touch Time",
"use_paint_scale_data": "Use Paint Scale Data"
},
"local_media_server": {
"enabled": "Enabled",
"http_path": "HTTP Path",
"network_path": "Network Path",
"token": "Token"
}
},
"md_categories": "Categories", "md_categories": "Categories",
"md_ccc_rates": "Courtesy Car Contract Rate Presets", "md_ccc_rates": "Courtesy Car Contract Rate Presets",
"md_classes": "Classes", "md_classes": "Classes",
@@ -516,13 +459,9 @@
"use_approvals": "Use Time Ticket Approval Queue" "use_approvals": "Use Time Ticket Approval Queue"
}, },
"messaginglabel": "Messaging Preset Label", "messaginglabel": "Messaging Preset Label",
"messaginglabel_short": "Label",
"messagingtext": "Messaging Preset Text", "messagingtext": "Messaging Preset Text",
"messagingtext_short": "Text",
"noteslabel": "Note Label", "noteslabel": "Note Label",
"noteslabel_short": "Label",
"notestext": "Note Text", "notestext": "Note Text",
"notestext_short": "Text",
"notifications": { "notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.", "description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"invalid_followers": "Invalid selection. Please select valid employees.", "invalid_followers": "Invalid selection. Please select valid employees.",
@@ -656,17 +595,12 @@
"federal_tax_itc": "Federal Tax Credit", "federal_tax_itc": "Federal Tax Credit",
"gogcode": "GOG Code (BreakOut)", "gogcode": "GOG Code (BreakOut)",
"gst_override": "GST Override Account #", "gst_override": "GST Override Account #",
"invoice_federal_tax_rate_short": "Federal Tax Rate",
"invoice_local_tax_rate_short": "Local Tax Rate",
"invoice_state_tax_rate_short": "State Tax Rate",
"invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code", "invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code",
"invoiceexemptcode_short": "Invoice Tax Exempt Code",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "Freight", "item_type_freight": "Freight",
"item_type_gog": "GOG", "item_type_gog": "GOG",
"item_type_paint": "Paint Materials", "item_type_paint": "Paint Materials",
"itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code", "itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code",
"itemexemptcode_short": "Line Item Tax Exempt Code",
"la1": "LA1", "la1": "LA1",
"la2": "LA2", "la2": "LA2",
"la3": "LA3", "la3": "LA3",
@@ -783,7 +717,6 @@
"customtemplates": "Custom Templates", "customtemplates": "Custom Templates",
"defaultcostsmapping": "Default Costs Mapping", "defaultcostsmapping": "Default Costs Mapping",
"defaultprofitsmapping": "Default Profits Mapping", "defaultprofitsmapping": "Default Profits Mapping",
"dms_setup": "DMS Setup",
"deliverchecklist": "Delivery Checklist", "deliverchecklist": "Delivery Checklist",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -800,33 +733,24 @@
}, },
"emaillater": "Email Later", "emaillater": "Email Later",
"employee_teams": "Employee Teams", "employee_teams": "Employee Teams",
"employee_options": "Employee Options",
"employee_rates": "Employee Rates",
"employee_vacation": "Employee Vacation",
"employees": "Employees", "employees": "Employees",
"estimators": "Estimators", "estimators": "Estimators",
"filehandlers": "Adjusters", "filehandlers": "Adjusters",
"imexpay": "ImEX Pay", "imexpay": "ImEX Pay",
"insurancecos": "Insurance Companies", "insurancecos": "Insurance Companies",
"intake_delivery": "Intake / Delivery Options",
"intakechecklist": "Intake Checklist", "intakechecklist": "Intake Checklist",
"intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ", "intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ",
"job_status_options": "Job Status Options",
"jobstatuses": "Job Statuses", "jobstatuses": "Job Statuses",
"laborrates": "Labor Rates", "laborrates": "Labor Rates",
"licensing": "Licensing", "licensing": "Licensing",
"md_parts_scan": "Parts Scan Rules", "md_parts_scan": "Parts Scan Rules",
"md_ro_guard": "RO Guard", "md_ro_guard": "RO Guard",
"md_ro_guard_options": "RO Guard Options",
"md_tasks_presets": "Tasks Presets", "md_tasks_presets": "Tasks Presets",
"task_preset_options": "Task Preset Options",
"md_to_emails": "Preset To Emails", "md_to_emails": "Preset To Emails",
"md_to_emails_emails": "Emails", "md_to_emails_emails": "Emails",
"messagingpresets": "Messaging Presets", "messagingpresets": "Messaging Presets",
"notification_options": "Notification Options",
"notemplatesavailable": "No templates available to add.", "notemplatesavailable": "No templates available to add.",
"notespresets": "Notes Presets", "notespresets": "Notes Presets",
"jump_to_section": "Jump to section",
"notifications": { "notifications": {
"followers": "Notifications" "followers": "Notifications"
}, },
@@ -840,22 +764,11 @@
"qbo_departmentid": "QBO Department ID", "qbo_departmentid": "QBO Department ID",
"qbo_usa": "QBO USA Compatibility", "qbo_usa": "QBO USA Compatibility",
"rbac": "Role Based Access Control", "rbac": "Role Based Access Control",
"rbac_options": "Role Based Access Control Options",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "Cost Centers", "costs": "Cost Centers",
"default_tax_setup": "Default Tax Setup",
"invoices": "Invoices",
"profits": "Profit Centers", "profits": "Profit Centers",
"quickbooks_qbd": "QuickBooks / QBD",
"quickbooks_us": "QuickBooks US",
"sales_tax_codes": "Sales Tax Codes", "sales_tax_codes": "Sales Tax Codes",
"tax_accounts": "Tax Accounts", "tax_accounts": "Tax Accounts",
"tax_rate_short": "Rate",
"tax_surcharge_short": "Surcharge",
"tax_threshold_short": "Threshold",
"tax_tier_card": "Tier {{typeNumIterator}}",
"tax_tier_short": "Tier",
"tax_type_card": "Tax Type {{typeNum}}",
"title": "Responsibility Centers", "title": "Responsibility Centers",
"ttl_adjustment": "Subtotal Adjustment Account", "ttl_adjustment": "Subtotal Adjustment Account",
"ttl_tax_adjustment": "Tax Adjustment Account" "ttl_tax_adjustment": "Tax Adjustment Account"
@@ -863,9 +776,6 @@
"roguard": { "roguard": {
"title": "RO Guard" "title": "RO Guard"
}, },
"autoemail": "Auto Email",
"jobcosting": "Job Costing",
"localmediaserver": "Local Media Server",
"romepay": "Rome Pay", "romepay": "Rome Pay",
"scheduling": "SMART Scheduling", "scheduling": "SMART Scheduling",
"scoreboardsetup": "Scoreboard Setup", "scoreboardsetup": "Scoreboard Setup",
@@ -873,7 +783,6 @@
"shopinfo": "Shop Information", "shopinfo": "Shop Information",
"shoprates": "Shop Rates", "shoprates": "Shop Rates",
"speedprint": "Speed Print Configuration", "speedprint": "Speed Print Configuration",
"speedprint_configurations": "Speed Print Configurations",
"ssbuckets": "Job Size Definitions", "ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings", "systemsettings": "System Settings",
"task-presets": "Task Presets", "task-presets": "Task Presets",
@@ -897,8 +806,7 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "md_parts_scan": {
"update_value_tooltip": "Some fields require coded values in order to function properly (e.g. labor and part types). Please reach out to support if you have any questions." "update_value_tooltip": "Some fields require coded values in order to function properly (e.g. labor and part types). Please reach out to support if you have any questions."
}, }
"reset-color": "Reset color"
}, },
"validation": { "validation": {
"centermustexist": "The chosen responsibility center does not exist.", "centermustexist": "The chosen responsibility center does not exist.",
@@ -1168,36 +1076,36 @@
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first." "earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
}, },
"labels": { "labels": {
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}", "refreshallocations": "Refresh to see DMS Allocations.",
"provider_reynolds": "Reynolds",
"provider_fortellis": "Fortellis",
"provider_cdk": "CDK",
"provider_pbs": "PBS",
"provider_dms": "DMS",
"transport_wss": "(WSS)",
"transport_ws": "(WS)",
"banner_status_connected": "Connected", "banner_status_connected": "Connected",
"banner_status_disconnected": "Disconnected", "banner_status_disconnected": "Disconnected",
"clear_logs": "Clear Logs", "banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
"collapse_all": "Collapse All", "reconnected_export_service": "Reconnected to {{provider}} Export Service",
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
"rr_validation_notice_title": "Reynolds RO created",
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
"color_json": "Color JSON", "color_json": "Color JSON",
"copied": "Copied", "plain_json": "Plain JSON",
"collapse_all": "Collapse All",
"expand_all": "Expand All",
"log_level": "Log Level",
"clear_logs": "Clear Logs",
"reconnect": "Reconnect",
"details": "Details",
"hide_details": "Hide details",
"copy": "Copy", "copy": "Copy",
"copied": "Copied",
"copy_request": "Copy Request", "copy_request": "Copy Request",
"copy_response": "Copy Response", "copy_response": "Copy Response",
"details": "Details",
"expand_all": "Expand All",
"hide_details": "Hide details",
"log_level": "Log Level",
"plain_json": "Plain JSON",
"provider_cdk": "CDK",
"provider_dms": "DMS",
"provider_fortellis": "Fortellis",
"provider_pbs": "PBS",
"provider_reynolds": "Reynolds",
"reconnect": "Reconnect",
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
"refreshallocations": "Refresh to see DMS Allocations.",
"request_xml": "Request XML", "request_xml": "Request XML",
"response_xml": "Response XML", "response_xml": "Response XML"
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
"rr_validation_notice_title": "Reynolds RO created",
"transport_ws": "(WS)",
"transport_wss": "(WSS)"
} }
}, },
"documents": { "documents": {
@@ -1266,8 +1174,7 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "New Team", "new": "New Team",
"newmember": "New Team Member", "newmember": "New Team Member"
"save_team": "Save Employee Team"
}, },
"errors": { "errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.", "allocation_total_exact": "Team allocation must total exactly 100%.",
@@ -1285,9 +1192,7 @@
"percentage": "Percent" "percentage": "Percent"
}, },
"labels": { "labels": {
"allocation_total": "Allocation Total: {{total}}%", "allocation_total": "Allocation Total: {{total}}%"
"members": "Members",
"team_options": "Team Options"
}, },
"options": { "options": {
"commission": "Commission", "commission": "Commission",
@@ -1297,11 +1202,9 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "Add Rate",
"addvacation": "Add Vacation", "addvacation": "Add Vacation",
"new": "New Employee", "new": "New Employee",
"newrate": "New Rate", "newrate": "New Rate",
"save_employee": "Save Employee",
"select": "Select Employee" "select": "Select Employee"
}, },
"errors": { "errors": {
@@ -1333,7 +1236,6 @@
"labels": { "labels": {
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"employee_number_short": "Employee #",
"endmustbeafterstart": "End date must be after start date.", "endmustbeafterstart": "End date must be after start date.",
"flat_rate": "Flat Rate", "flat_rate": "Flat Rate",
"inactive": "Inactive", "inactive": "Inactive",
@@ -1466,7 +1368,6 @@
"beta": "BETA", "beta": "BETA",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.", "cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log", "changelog": "Change Log",
"click_to_begin": "Click {{action}} to begin",
"clear": "Clear", "clear": "Clear",
"confirmpassword": "Confirm Password", "confirmpassword": "Confirm Password",
"created_at": "Created At", "created_at": "Created At",
@@ -1785,7 +1686,6 @@
}, },
"jobs": { "jobs": {
"actions": { "actions": {
"addpayer": "Add Payer",
"addDocuments": "Add Job Documents", "addDocuments": "Add Job Documents",
"addNote": "Add Note", "addNote": "Add Note",
"addtopartsqueue": "Add to Parts Queue", "addtopartsqueue": "Add to Parts Queue",
@@ -2013,15 +1913,10 @@
"employee_refinish": "Refinish", "employee_refinish": "Refinish",
"est_addr1": "Estimator Address", "est_addr1": "Estimator Address",
"est_co_nm": "Estimator Company", "est_co_nm": "Estimator Company",
"est_co_nm_short": "Company",
"est_ct_fn": "Estimator First Name", "est_ct_fn": "Estimator First Name",
"est_ct_fn_short": "First Name",
"est_ct_ln": "Estimator Last Name", "est_ct_ln": "Estimator Last Name",
"est_ct_ln_short": "Last Name",
"est_ea": "Estimator Email", "est_ea": "Estimator Email",
"est_ea_short": "Email",
"est_ph1": "Estimator Phone #", "est_ph1": "Estimator Phone #",
"est_ph1_short": "Phone #",
"estimate_approved": "Estimate Approved", "estimate_approved": "Estimate Approved",
"estimate_sent_approval": "Estimate Sent for Approval", "estimate_sent_approval": "Estimate Sent for Approval",
"federal_tax_payable": "Federal Tax Payable", "federal_tax_payable": "Federal Tax Payable",
@@ -2034,13 +1929,9 @@
"ins_co_nm": "Insurance Company Name", "ins_co_nm": "Insurance Company Name",
"ins_co_nm_short": "Ins. Co.", "ins_co_nm_short": "Ins. Co.",
"ins_ct_fn": "Adjuster First Name", "ins_ct_fn": "Adjuster First Name",
"ins_ct_fn_short": "First Name",
"ins_ct_ln": "Adjuster Last Name", "ins_ct_ln": "Adjuster Last Name",
"ins_ct_ln_short": "Last Name",
"ins_ea": "Adjuster Email", "ins_ea": "Adjuster Email",
"ins_ea_short": "Email",
"ins_ph1": "Adjuster Phone #", "ins_ph1": "Adjuster Phone #",
"ins_ph1_short": "Phone #",
"intake": { "intake": {
"label": "Label", "label": "Label",
"max": "Maximum", "max": "Maximum",

View File

@@ -121,7 +121,7 @@
"assignedlinehours": "", "assignedlinehours": "",
"billdeleted": "", "billdeleted": "",
"billposted": "", "billposted": "",
"billmarkforreexport": "", "billupdated": "",
"failedpayment": "", "failedpayment": "",
"jobassignmentchange": "", "jobassignmentchange": "",
"jobassignmentremoved": "", "jobassignmentremoved": "",
@@ -231,16 +231,13 @@
"overall": "" "overall": ""
}, },
"disclaimer_title": "", "disclaimer_title": "",
"feedback_placeholder": "",
"feedback_prompt": "",
"generic_failure": "", "generic_failure": "",
"multipage": "", "multipage": "",
"processing": "", "processing": "",
"scan": "", "scan": "",
"scancomplete": "", "scancomplete": "",
"scanfailed": "", "scanfailed": "",
"scanstarted": "", "scanstarted": ""
"submit_feedback": ""
}, },
"bill_lines": "", "bill_lines": "",
"bill_total": "", "bill_total": "",
@@ -292,23 +289,7 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "actions": {
"add_adjuster": "",
"add_control_number": "",
"add_cost_center": "",
"add_courtesy_car_rate_preset": "",
"add_delivery_checklist_item": "",
"add_dms_allocation": "",
"add_estimator": "",
"add_insurance_company": "",
"add_intake_checklist_item": "",
"add_jobline_preset": "",
"add_messaging_preset": "",
"add_note_preset": "",
"add_parts_order_comment": "",
"add_production_status_color": "",
"add_profit_center": "",
"add_task_preset": "", "add_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "", "addapptcolor": "",
"addbucket": "", "addbucket": "",
"addpartslocation": "", "addpartslocation": "",
@@ -317,13 +298,11 @@
"addtemplate": "", "addtemplate": "",
"newlaborrate": "", "newlaborrate": "",
"newsalestaxcode": "", "newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "", "newstatus": "",
"testrender": "" "testrender": ""
}, },
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "", "duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.", "loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "", "saving": "",
@@ -357,7 +336,6 @@
"require_actual_delivery_date": "", "require_actual_delivery_date": "",
"templates": "" "templates": ""
}, },
"disableBillCostCalculation": "",
"dms": { "dms": {
"apcontrol": "", "apcontrol": "",
"appostingaccount": "", "appostingaccount": "",
@@ -421,35 +399,6 @@
"logo_img_path": "", "logo_img_path": "",
"logo_img_path_height": "", "logo_img_path_height": "",
"logo_img_path_width": "", "logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "", "md_categories": "",
"md_ccc_rates": "", "md_ccc_rates": "",
"md_classes": "", "md_classes": "",
@@ -510,13 +459,9 @@
"use_approvals": "" "use_approvals": ""
}, },
"messaginglabel": "", "messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "", "messagingtext": "",
"messagingtext_short": "",
"noteslabel": "", "noteslabel": "",
"noteslabel_short": "",
"notestext": "", "notestext": "",
"notestext_short": "",
"notifications": { "notifications": {
"description": "", "description": "",
"invalid_followers": "", "invalid_followers": "",
@@ -650,17 +595,12 @@
"federal_tax_itc": "", "federal_tax_itc": "",
"gogcode": "", "gogcode": "",
"gst_override": "", "gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "", "invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "", "item_type_freight": "",
"item_type_gog": "", "item_type_gog": "",
"item_type_paint": "", "item_type_paint": "",
"itemexemptcode": "", "itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "", "la1": "",
"la2": "", "la2": "",
"la3": "", "la3": "",
@@ -777,7 +717,6 @@
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
"defaultprofitsmapping": "", "defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "", "deliverchecklist": "",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -794,33 +733,24 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
"imexpay": "", "imexpay": "",
"insurancecos": "", "insurancecos": "",
"intake_delivery": "",
"intakechecklist": "", "intakechecklist": "",
"intellipay_cash_discount": "", "intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "", "jobstatuses": "",
"laborrates": "", "laborrates": "",
"licensing": "", "licensing": "",
"md_parts_scan": "", "md_parts_scan": "",
"md_ro_guard": "", "md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "", "md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "", "md_to_emails": "",
"md_to_emails_emails": "", "md_to_emails_emails": "",
"messagingpresets": "", "messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"jump_to_section": "",
"notifications": { "notifications": {
"followers": "" "followers": ""
}, },
@@ -834,22 +764,11 @@
"qbo_departmentid": "", "qbo_departmentid": "",
"qbo_usa": "", "qbo_usa": "",
"rbac": "", "rbac": "",
"rbac_options": "",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "", "costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "", "profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "", "sales_tax_codes": "",
"tax_accounts": "", "tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "", "title": "",
"ttl_adjustment": "", "ttl_adjustment": "",
"ttl_tax_adjustment": "" "ttl_tax_adjustment": ""
@@ -857,9 +776,6 @@
"roguard": { "roguard": {
"title": "" "title": ""
}, },
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "", "romepay": "",
"scheduling": "", "scheduling": "",
"scoreboardsetup": "", "scoreboardsetup": "",
@@ -867,7 +783,6 @@
"shopinfo": "", "shopinfo": "",
"shoprates": "", "shoprates": "",
"speedprint": "", "speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
@@ -891,8 +806,7 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "md_parts_scan": {
"update_value_tooltip": "" "update_value_tooltip": ""
}, }
"reset-color": ""
}, },
"validation": { "validation": {
"centermustexist": "", "centermustexist": "",
@@ -1162,36 +1076,36 @@
"earlyrorequired.message": "" "earlyrorequired.message": ""
}, },
"labels": { "labels": {
"banner_message": "", "refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "", "banner_status_connected": "",
"banner_status_disconnected": "", "banner_status_disconnected": "",
"clear_logs": "", "banner_message": "",
"collapse_all": "", "reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "", "color_json": "",
"copied": "", "plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "", "copy": "",
"copied": "",
"copy_request": "", "copy_request": "",
"copy_response": "", "copy_response": "",
"details": "",
"expand_all": "",
"hide_details": "",
"log_level": "",
"plain_json": "",
"provider_cdk": "",
"provider_dms": "",
"provider_fortellis": "",
"provider_pbs": "",
"provider_reynolds": "",
"reconnect": "",
"reconnected_export_service": "",
"refreshallocations": "",
"request_xml": "", "request_xml": "",
"response_xml": "", "response_xml": ""
"rr_validation_message": "",
"rr_validation_notice_description": "",
"rr_validation_notice_title": "",
"transport_ws": "",
"transport_wss": ""
} }
}, },
"documents": { "documents": {
@@ -1260,8 +1174,7 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "", "new": "",
"newmember": "", "newmember": ""
"save_team": ""
}, },
"errors": { "errors": {
"allocation_total_exact": "", "allocation_total_exact": "",
@@ -1279,9 +1192,7 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "", "allocation_total": ""
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1291,11 +1202,9 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "",
"addvacation": "", "addvacation": "",
"new": "Nuevo empleado", "new": "Nuevo empleado",
"newrate": "", "newrate": "",
"save_employee": "",
"select": "" "select": ""
}, },
"errors": { "errors": {
@@ -1327,7 +1236,6 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",
@@ -1460,7 +1368,6 @@
"beta": "", "beta": "",
"cancel": "", "cancel": "",
"changelog": "", "changelog": "",
"click_to_begin": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1779,7 +1686,6 @@
}, },
"jobs": { "jobs": {
"actions": { "actions": {
"addpayer": "",
"addDocuments": "Agregar documentos de trabajo", "addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota", "addNote": "Añadir la nota",
"addtopartsqueue": "", "addtopartsqueue": "",
@@ -2007,15 +1913,10 @@
"employee_refinish": "", "employee_refinish": "",
"est_addr1": "Dirección del tasador", "est_addr1": "Dirección del tasador",
"est_co_nm": "Tasador", "est_co_nm": "Tasador",
"est_co_nm_short": "",
"est_ct_fn": "Nombre del tasador", "est_ct_fn": "Nombre del tasador",
"est_ct_fn_short": "",
"est_ct_ln": "Apellido del tasador", "est_ct_ln": "Apellido del tasador",
"est_ct_ln_short": "",
"est_ea": "Correo electrónico del tasador", "est_ea": "Correo electrónico del tasador",
"est_ea_short": "",
"est_ph1": "Número de teléfono del tasador", "est_ph1": "Número de teléfono del tasador",
"est_ph1_short": "",
"estimate_approved": "", "estimate_approved": "",
"estimate_sent_approval": "", "estimate_sent_approval": "",
"federal_tax_payable": "Impuesto federal por pagar", "federal_tax_payable": "Impuesto federal por pagar",
@@ -2028,13 +1929,9 @@
"ins_co_nm": "Nombre de la compañía de seguros", "ins_co_nm": "Nombre de la compañía de seguros",
"ins_co_nm_short": "", "ins_co_nm_short": "",
"ins_ct_fn": "Nombre del controlador de archivos", "ins_ct_fn": "Nombre del controlador de archivos",
"ins_ct_fn_short": "",
"ins_ct_ln": "Apellido del manejador de archivos", "ins_ct_ln": "Apellido del manejador de archivos",
"ins_ct_ln_short": "",
"ins_ea": "Correo electrónico del controlador de archivos", "ins_ea": "Correo electrónico del controlador de archivos",
"ins_ea_short": "",
"ins_ph1": "File Handler Phone #", "ins_ph1": "File Handler Phone #",
"ins_ph1_short": "",
"intake": { "intake": {
"label": "", "label": "",
"max": "", "max": "",

View File

@@ -120,8 +120,8 @@
"appointmentinsert": "", "appointmentinsert": "",
"assignedlinehours": "", "assignedlinehours": "",
"billdeleted": "", "billdeleted": "",
"billmarkforreexport": "",
"billposted": "", "billposted": "",
"billupdated": "",
"failedpayment": "", "failedpayment": "",
"jobassignmentchange": "", "jobassignmentchange": "",
"jobassignmentremoved": "", "jobassignmentremoved": "",
@@ -231,16 +231,13 @@
"overall": "" "overall": ""
}, },
"disclaimer_title": "", "disclaimer_title": "",
"feedback_placeholder": "",
"feedback_prompt": "",
"generic_failure": "", "generic_failure": "",
"multipage": "", "multipage": "",
"processing": "", "processing": "",
"scan": "", "scan": "",
"scancomplete": "", "scancomplete": "",
"scanfailed": "", "scanfailed": "",
"scanstarted": "", "scanstarted": ""
"submit_feedback": ""
}, },
"bill_lines": "", "bill_lines": "",
"bill_total": "", "bill_total": "",
@@ -292,23 +289,7 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "actions": {
"add_adjuster": "",
"add_control_number": "",
"add_cost_center": "",
"add_courtesy_car_rate_preset": "",
"add_delivery_checklist_item": "",
"add_dms_allocation": "",
"add_estimator": "",
"add_insurance_company": "",
"add_intake_checklist_item": "",
"add_jobline_preset": "",
"add_messaging_preset": "",
"add_note_preset": "",
"add_parts_order_comment": "",
"add_production_status_color": "",
"add_profit_center": "",
"add_task_preset": "", "add_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "", "addapptcolor": "",
"addbucket": "", "addbucket": "",
"addpartslocation": "", "addpartslocation": "",
@@ -317,13 +298,11 @@
"addtemplate": "", "addtemplate": "",
"newlaborrate": "", "newlaborrate": "",
"newsalestaxcode": "", "newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "", "newstatus": "",
"testrender": "" "testrender": ""
}, },
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "", "duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.", "loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "", "saving": "",
@@ -357,7 +336,6 @@
"require_actual_delivery_date": "", "require_actual_delivery_date": "",
"templates": "" "templates": ""
}, },
"disableBillCostCalculation": "",
"dms": { "dms": {
"apcontrol": "", "apcontrol": "",
"appostingaccount": "", "appostingaccount": "",
@@ -421,35 +399,6 @@
"logo_img_path": "", "logo_img_path": "",
"logo_img_path_height": "", "logo_img_path_height": "",
"logo_img_path_width": "", "logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "", "md_categories": "",
"md_ccc_rates": "", "md_ccc_rates": "",
"md_classes": "", "md_classes": "",
@@ -510,13 +459,9 @@
"use_approvals": "" "use_approvals": ""
}, },
"messaginglabel": "", "messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "", "messagingtext": "",
"messagingtext_short": "",
"noteslabel": "", "noteslabel": "",
"noteslabel_short": "",
"notestext": "", "notestext": "",
"notestext_short": "",
"notifications": { "notifications": {
"description": "", "description": "",
"invalid_followers": "", "invalid_followers": "",
@@ -650,17 +595,12 @@
"federal_tax_itc": "", "federal_tax_itc": "",
"gogcode": "", "gogcode": "",
"gst_override": "", "gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "", "invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "", "item_type_freight": "",
"item_type_gog": "", "item_type_gog": "",
"item_type_paint": "", "item_type_paint": "",
"itemexemptcode": "", "itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "", "la1": "",
"la2": "", "la2": "",
"la3": "", "la3": "",
@@ -777,7 +717,6 @@
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
"defaultprofitsmapping": "", "defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "", "deliverchecklist": "",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -794,33 +733,24 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
"imexpay": "", "imexpay": "",
"insurancecos": "", "insurancecos": "",
"intake_delivery": "",
"intakechecklist": "", "intakechecklist": "",
"intellipay_cash_discount": "", "intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "", "jobstatuses": "",
"laborrates": "", "laborrates": "",
"licensing": "", "licensing": "",
"md_parts_scan": "", "md_parts_scan": "",
"md_ro_guard": "", "md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "", "md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "", "md_to_emails": "",
"md_to_emails_emails": "", "md_to_emails_emails": "",
"messagingpresets": "", "messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"jump_to_section": "",
"notifications": { "notifications": {
"followers": "" "followers": ""
}, },
@@ -834,22 +764,11 @@
"qbo_departmentid": "", "qbo_departmentid": "",
"qbo_usa": "", "qbo_usa": "",
"rbac": "", "rbac": "",
"rbac_options": "",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "", "costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "", "profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "", "sales_tax_codes": "",
"tax_accounts": "", "tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "", "title": "",
"ttl_adjustment": "", "ttl_adjustment": "",
"ttl_tax_adjustment": "" "ttl_tax_adjustment": ""
@@ -857,9 +776,6 @@
"roguard": { "roguard": {
"title": "" "title": ""
}, },
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "", "romepay": "",
"scheduling": "", "scheduling": "",
"scoreboardsetup": "", "scoreboardsetup": "",
@@ -867,7 +783,6 @@
"shopinfo": "", "shopinfo": "",
"shoprates": "", "shoprates": "",
"speedprint": "", "speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
@@ -891,8 +806,7 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "md_parts_scan": {
"update_value_tooltip": "" "update_value_tooltip": ""
}, }
"reset-color": ""
}, },
"validation": { "validation": {
"centermustexist": "", "centermustexist": "",
@@ -1162,36 +1076,36 @@
"earlyrorequired.message": "" "earlyrorequired.message": ""
}, },
"labels": { "labels": {
"banner_message": "", "refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "", "banner_status_connected": "",
"banner_status_disconnected": "", "banner_status_disconnected": "",
"clear_logs": "", "banner_message": "",
"collapse_all": "", "reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "", "color_json": "",
"copied": "", "plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "", "copy": "",
"copied": "",
"copy_request": "", "copy_request": "",
"copy_response": "", "copy_response": "",
"details": "",
"expand_all": "",
"hide_details": "",
"log_level": "",
"plain_json": "",
"provider_cdk": "",
"provider_dms": "",
"provider_fortellis": "",
"provider_pbs": "",
"provider_reynolds": "",
"reconnect": "",
"reconnected_export_service": "",
"refreshallocations": "",
"request_xml": "", "request_xml": "",
"response_xml": "", "response_xml": ""
"rr_validation_message": "",
"rr_validation_notice_description": "",
"rr_validation_notice_title": "",
"transport_ws": "",
"transport_wss": ""
} }
}, },
"documents": { "documents": {
@@ -1260,8 +1174,7 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "", "new": "",
"newmember": "", "newmember": ""
"save_team": ""
}, },
"errors": { "errors": {
"allocation_total_exact": "", "allocation_total_exact": "",
@@ -1279,9 +1192,7 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "", "allocation_total": ""
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1291,11 +1202,9 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "",
"addvacation": "", "addvacation": "",
"new": "Nouvel employé", "new": "Nouvel employé",
"newrate": "", "newrate": "",
"save_employee": "",
"select": "" "select": ""
}, },
"errors": { "errors": {
@@ -1327,7 +1236,6 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",
@@ -1460,7 +1368,6 @@
"beta": "", "beta": "",
"cancel": "", "cancel": "",
"changelog": "", "changelog": "",
"click_to_begin": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1779,7 +1686,6 @@
}, },
"jobs": { "jobs": {
"actions": { "actions": {
"addpayer": "",
"addDocuments": "Ajouter des documents de travail", "addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note", "addNote": "Ajouter une note",
"addtopartsqueue": "", "addtopartsqueue": "",
@@ -2007,15 +1913,10 @@
"employee_refinish": "", "employee_refinish": "",
"est_addr1": "Adresse de l'évaluateur", "est_addr1": "Adresse de l'évaluateur",
"est_co_nm": "Expert", "est_co_nm": "Expert",
"est_co_nm_short": "",
"est_ct_fn": "Prénom de l'évaluateur", "est_ct_fn": "Prénom de l'évaluateur",
"est_ct_fn_short": "",
"est_ct_ln": "Nom de l'évaluateur", "est_ct_ln": "Nom de l'évaluateur",
"est_ct_ln_short": "",
"est_ea": "Courriel de l'évaluateur", "est_ea": "Courriel de l'évaluateur",
"est_ea_short": "",
"est_ph1": "Numéro de téléphone de l'évaluateur", "est_ph1": "Numéro de téléphone de l'évaluateur",
"est_ph1_short": "",
"estimate_approved": "", "estimate_approved": "",
"estimate_sent_approval": "", "estimate_sent_approval": "",
"federal_tax_payable": "Impôt fédéral à payer", "federal_tax_payable": "Impôt fédéral à payer",
@@ -2028,13 +1929,9 @@
"ins_co_nm": "Nom de la compagnie d'assurance", "ins_co_nm": "Nom de la compagnie d'assurance",
"ins_co_nm_short": "", "ins_co_nm_short": "",
"ins_ct_fn": "Prénom du gestionnaire de fichiers", "ins_ct_fn": "Prénom du gestionnaire de fichiers",
"ins_ct_fn_short": "",
"ins_ct_ln": "Nom du gestionnaire de fichiers", "ins_ct_ln": "Nom du gestionnaire de fichiers",
"ins_ct_ln_short": "",
"ins_ea": "Courriel du gestionnaire de fichiers", "ins_ea": "Courriel du gestionnaire de fichiers",
"ins_ea_short": "",
"ins_ph1": "Numéro de téléphone du gestionnaire de fichiers", "ins_ph1": "Numéro de téléphone du gestionnaire de fichiers",
"ins_ph1_short": "",
"intake": { "intake": {
"label": "", "label": "",
"max": "", "max": "",

View File

@@ -8,9 +8,8 @@ const AuditTrailMapping = {
appointmentcancel: (lost_sale_reason) => i18n.t("audit_trail.messages.appointmentcancel", { lost_sale_reason }), appointmentcancel: (lost_sale_reason) => i18n.t("audit_trail.messages.appointmentcancel", { lost_sale_reason }),
appointmentinsert: (start) => i18n.t("audit_trail.messages.appointmentinsert", { start }), appointmentinsert: (start) => i18n.t("audit_trail.messages.appointmentinsert", { start }),
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }), billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }), billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }), billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }),
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }), jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }), jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
jobchecklist: (type, inproduction, status) => jobchecklist: (type, inproduction, status) =>
@@ -26,10 +25,6 @@ const AuditTrailMapping = {
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }), jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"), jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"), jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
joblineupdate: (lineDescription, details) =>
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }), jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"), jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"), jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
@@ -76,11 +71,7 @@ const AuditTrailMapping = {
i18n.t("audit_trail.messages.tasks_uncompleted", { i18n.t("audit_trail.messages.tasks_uncompleted", {
title, title,
uncompletedBy uncompletedBy
}), })
timeticketcreated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
timeticketupdated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
}; };
export default AuditTrailMapping; export default AuditTrailMapping;

View File

@@ -5,13 +5,15 @@ import { RetryLink } from "@apollo/client/link/retry";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities"; import { getMainDefinition } from "@apollo/client/utilities";
import apolloLogger from "apollo-link-logger"; import apolloLoggerModule from "apollo-link-logger";
import { createClient } from "graphql-ws"; import { createClient } from "graphql-ws";
import { map } from "rxjs/operators"; import { map } from "rxjs/operators";
import { auth } from "../firebase/firebase.utils"; import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling"; import errorLink from "../graphql/apollo-error-handling";
const apolloLogger = apolloLoggerModule?.default ?? apolloLoggerModule;
/** /**
* HTTP transport * HTTP transport
*/ */

View File

@@ -1,186 +0,0 @@
import dayjs from "./day";
const EMPTY_VALUE = "<<empty>>";
const NO_CHANGES = "No changes";
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
const DATE_ONLY_KEYS = new Set(["date"]);
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
const isBlank = (value) => value == null || value === "";
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
const formatNumber = (value, fractionDigits) =>
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
const compareValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
return String(value);
};
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
keys
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
.map((key) => {
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
})
.filter(Boolean);
const formatBillValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
return String(value);
};
const formatJobLineValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
return String(value);
};
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
if (
(employeeId == null || fallbackEmployee?.id === employeeId) &&
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
) {
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
}
const employee = employees.find(({ id }) => id === employeeId);
if (employee) {
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
}
return employeeId ? String(employeeId) : EMPTY_VALUE;
};
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
if (isBlank(value)) return EMPTY_VALUE;
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
};
const buildBillLineSummary = (line) =>
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
const updatedBill = { ...bill, billlines };
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
(key) => key !== "billlines"
);
const changed = buildFieldChangeDetails({
keys: billKeys,
original: originalBill,
updated: updatedBill,
displayValue: formatBillValue
});
const originalBillLines = originalBill.billlines ?? [];
const updatedBillLines = updatedBill.billlines ?? [];
const addedLines = updatedBillLines
.filter((line) => !line.id)
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
const removedLines = originalBillLines
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
.map(
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
);
const modifiedLines = updatedBillLines
.filter((line) => line.id)
.flatMap((line) => {
const originalLine = originalBillLines.find(({ id }) => id === line.id);
if (!originalLine) return [];
const lineChanges = buildFieldChangeDetails({
keys: BILL_LINE_KEYS,
original: originalLine,
updated: line,
displayValue: formatBillValue
});
if (!lineChanges.length) return [];
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
});
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
return changed.length ? changed.join("; ") : NO_CHANGES;
}
export function buildJobLineInsertAuditDetails(values = {}) {
const details = Object.entries(values)
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
const details = buildFieldChangeDetails({
keys: Object.keys(values),
original: originalLine,
updated: values,
displayValue: formatJobLineValue,
skippedKeys: JOB_LINE_SKIP_KEYS
});
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
const normalizedOriginal = {
...originalTicket,
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
};
const details = buildFieldChangeDetails({
keys: Object.keys(submittedValues),
original: normalizedOriginal,
updated: submittedValues,
displayValue: (key, value) =>
formatTimeTicketValue(key, value, {
employees,
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
})
});
const employeeName = getEmployeeName(
submittedValues.employeeid ?? normalizedOriginal.employeeid,
employees,
normalizedOriginal.employee
);
return {
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
details: details.length ? details.join("; ") : NO_CHANGES,
employeeName,
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
};
}

View File

@@ -1,140 +0,0 @@
/* eslint-disable */
import { expect, test } from "@playwright/test";
import { acceptEulaIfPresent, login } from "./utils/login";
async function openCommissionCutHarness(page) {
await page.goto("/manage/_test?fixture=commission-cut");
await acceptEulaIfPresent(page);
await expect(page.getByRole("heading", { name: "Commission Cut Test Harness" })).toBeVisible();
}
test.describe("Commission-based cut", () => {
test.skip(!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD, "Requires TEST_USERNAME and TEST_PASSWORD.");
test("renders payout previews and completes Pay All from the commission-cut harness", async ({ page }) => {
let calculateLaborCalls = 0;
let payAllCalls = 0;
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
calculateLaborCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
},
{
employeeid: "emp-2",
mod_lbr_ty: "LAB",
expectedHours: 2,
claimedHours: 1
}
])
});
});
await page.route("**/payroll/payall", async (route) => {
payAllCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: "tt-1" }])
});
});
await openCommissionCutHarness(page);
await expect(page.getByText("Claim Task Preview")).toBeVisible();
await expect(page.getByRole("cell", { name: "Commission" })).toBeVisible();
await expect(page.getByRole("cell", { name: "Hourly" })).toBeVisible();
await expect(
page.getByText(
"There are currently 1.25 hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually."
)
).toBeVisible();
await expect(page.getByRole("button", { name: "Pay All" })).toBeVisible();
await page.getByRole("button", { name: "Pay All" }).click();
await expect.poll(() => calculateLaborCalls).toBeGreaterThan(0);
await expect.poll(() => payAllCalls).toBe(1);
await expect(page.getByText("All hours paid out successfully.")).toBeVisible();
});
test("shows the backend error when Pay All is rejected", async ({ page }) => {
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
])
});
});
await page.route("**/payroll/payall", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: false,
error: "Not all hours have been assigned."
})
});
});
await openCommissionCutHarness(page);
await page.getByRole("button", { name: "Pay All" }).click();
await expect(page.getByText("Error flagging hours. Not all hours have been assigned.")).toBeVisible();
});
test("shows a negative labor difference when previously claimed hours exceed the current expected hours", async ({
page
}) => {
await login(page, {
email: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
});
await page.route("**/payroll/calculatelabor", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
employeeid: "emp-1",
mod_lbr_ty: "LAA",
expectedHours: 2,
claimedHours: 5
}
])
});
});
await openCommissionCutHarness(page);
await expect(page.locator("strong").filter({ hasText: "-3" }).first()).toBeVisible();
});
});

View File

@@ -1,48 +1,5 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
const formatToday = () => {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const year = today.getFullYear();
return `${month}/${day}/${year}`;
};
export async function acceptEulaIfPresent(page) {
const eulaDialog = page.getByRole("dialog", { name: "Terms and Conditions" });
const eulaVisible =
(await eulaDialog.isVisible().catch(() => false)) ||
(await eulaDialog
.waitFor({
state: "visible",
timeout: 5000
})
.then(() => true)
.catch(() => false));
if (!eulaVisible) {
return;
}
const markdownCard = page.locator(".eula-markdown-card");
await markdownCard.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.getByRole("textbox", { name: "First Name" }).fill("Codex");
await page.getByRole("textbox", { name: "Last Name" }).fill("Tester");
await page.getByRole("textbox", { name: "Legal Business Name" }).fill("Codex QA");
await page.getByRole("textbox", { name: "Date Accepted" }).fill(formatToday());
await page.getByRole("checkbox", { name: "I accept the terms and conditions of this agreement." }).check();
const acceptButton = page.getByRole("button", { name: "Accept EULA" });
await expect(acceptButton).toBeEnabled({ timeout: 10000 });
await acceptButton.click();
await expect(eulaDialog).not.toBeVisible({ timeout: 10000 });
}
export async function login(page, { email, password }) { export async function login(page, { email, password }) {
// Navigate to the login page // Navigate to the login page
await page.goto("/"); // Adjust if your login route differs (e.g., '/login') await page.goto("/"); // Adjust if your login route differs (e.g., '/login')
@@ -59,8 +16,6 @@ export async function login(page, { email, password }) {
// Wait for navigation or success indicator (e.g., redirect to /manage/) // Wait for navigation or success indicator (e.g., redirect to /manage/)
await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect
await acceptEulaIfPresent(page);
// Verify successful login (e.g., check for a dashboard element) // Verify successful login (e.g., check for a dashboard element)
await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your apps post-login UI await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your apps post-login UI
} }

Some files were not shown because too many files have changed in this diff Show More