diff --git a/_reference/commission-based-cut-manual-test-plan.md b/_reference/commission-based-cut-manual-test-plan.md new file mode 100644 index 000000000..df94cc3f4 --- /dev/null +++ b/_reference/commission-based-cut-manual-test-plan.md @@ -0,0 +1,424 @@ +# Commission-Based Cut Feature Manual Test Plan + +## Purpose +Use this guide to manually test the commission-based cut feature from an end-user point of view. + +This plan is written for a non-technical tester. Follow the steps exactly as written and mark each scenario as Pass or Fail. + +## What You Need Before You Start +- A login that can open `Manage my Shop`, `Jobs`, and `Time Tickets`. +- At least 2 active employees in the shop. +- At least 1 converted repair order that already has labor lines on it. +- If possible, use a simple test job where the labor sale rates are easy to calculate. +- A notebook, spreadsheet, or screenshot folder to record what happened. + +## Recommended Easy-Math Test Data +If you can choose your own test job, use something simple like this: + +- Body sale rate: `$100.00` +- Refinish sale rate: `$120.00` +- Mechanical sale rate: `$80.00` +- 1 Body labor line with `10.0` hours +- 1 Refinish labor line with `4.0` hours + +This makes the expected payout easy to check: + +- `40%` of `$100.00` = `$40.00` +- `30%` of `$120.00` = `$36.00` + +## Important Navigation Notes +- Team setup is under `Manage my Shop` > `Employee Teams`. +- Team assignment happens on the job line grid in the `Team` column. +- Automatic payout happens from the job's `Labor Allocations` card using the `Pay All` button. +- If your shop uses task presets, the `Flag Hours` button can preview the payout method before committing tickets. + +--- + +## Scenario 1: Create a Simple Commission Team +### Goal +Confirm a team member can be set to commission and saved successfully. + +### Steps +1. Sign in. +2. Click `Manage my Shop`. +3. Click the `Employee Teams` tab. +4. Click `New Team`. +5. In `Team Name`, type `Commission Team Test`. +6. Make sure `Active` is turned on. +7. In `Max Load`, enter `10`. +8. Click `New Team Member`. +9. In `Employee`, choose an active employee. +10. In `Allocation %`, enter `100`. +11. In `Payout Method`, choose `Commission %`. +12. In each commission field that appears, enter a value. +13. For the main labor types you plan to test, use these values: +14. Enter `40` for Body. +15. Enter `30` for Refinish. +16. Enter `25` for Mechanical. +17. Enter `20` for Frame. +18. Enter `15` for Glass. +19. Fill in the remaining commission boxes with any valid number from `0` to `100`. +20. Click `Save`. + +### Expected Result +- The team saves successfully. +- The team stays visible in the Employee Teams list. +- The team member card shows a `Commission` tag. +- The `Allocation Total` shows `100%`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 2: Allocation Total Must Equal 100% +### Goal +Confirm the system blocks a team that does not total exactly 100%. + +### Steps +1. Stay on the same team. +2. Change `Allocation %` from `100` to `90`. +3. Click `Save`. +4. Change `Allocation %` from `90` to `110`. +5. Click `Save`. +6. Change `Allocation %` back to `100`. +7. Click `Save` again. + +### Expected Result +- When the total is `90%`, the system should not save. +- When the total is `110%`, the system should not save. +- The page should show that the allocation total is not correct. +- When the total is set back to `100%`, the save should succeed. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 3: The Same Employee Cannot Be Added Twice +### Goal +Confirm the same employee cannot appear twice on one team. + +### Steps +1. Open the same team again. +2. Click `New Team Member`. +3. Choose the same employee already used on the team. +4. Enter any valid allocation amount. +5. Choose `Commission %`. +6. Fill in all required commission fields. +7. Click `Save`. + +### Expected Result +- The system should block the save. +- The team should not save with the same employee listed twice. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 4: Switching Between Hourly and Commission Changes the Input Style +### Goal +Confirm the rate section changes correctly when the payout method changes. + +### Steps +1. Open the same team again. +2. On the team member row, change `Payout Method` from `Commission %` to `Hourly`. +3. Look at the rate fields that appear. +4. Change `Payout Method` back to `Commission %`. +5. Look at the rate fields again. + +### Expected Result +- In `Hourly` mode, the rate boxes should behave like money/rate fields. +- In `Commission %` mode, the rate boxes should behave like percentage fields. +- The screen should clearly show you are editing the correct type of value. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 5: Boundary Values for Commission % +### Goal +Confirm the feature accepts valid boundary values and blocks invalid ones. + +### Steps +1. Open the team again. +2. In one commission box, enter `0`. +3. In another commission box, enter `100`. +4. Click `Save`. +5. Try to type a value above `100` in one of the commission boxes. +6. Try to type a negative value in one of the commission boxes. + +### Expected Result +- `0` should be accepted. +- `100` should be accepted. +- Values above `100` should not be allowed or should fail validation. +- Negative values should not be allowed or should fail validation. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 6: Inactive Teams Should Not Be Offered for New Assignment +### Goal +Confirm inactive teams do not appear as normal team choices. + +### Steps +1. Open the team again. +2. Turn `Active` off. +3. Click `Save`. +4. Open a converted repair order. +5. Go to the job lines area where the `Team` column is visible. +6. Click inside the `Team` field on any labor line. +7. Open the team drop-down list. +8. Look for `Commission Team Test`. +9. Go back to `Manage my Shop` > `Employee Teams`. +10. Turn `Active` back on. +11. Click `Save`. +12. Return to the same job line and open the `Team` drop-down again. + +### Expected Result +- When the team is inactive, it should not appear as a normal assignment choice. +- After turning it back on, it should appear again. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 7: Assign the Commission Team to a Labor Line +### Goal +Confirm the team can be assigned to a job line from the job screen. + +### Steps +1. Open a converted repair order that has labor lines. +2. Find a labor line in the job line grid. +3. In the `Team` column, click the blank area or the current team name. +4. From the drop-down list, choose `Commission Team Test`. +5. Click outside the field so it saves. +6. Repeat for at least 1 Body line and 1 Refinish line if both exist. + +### Expected Result +- The selected team name should appear in the `Team` column. +- The assignment should stay in place after the screen refreshes. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 8: Pay All Creates Commission-Based Tickets +### Goal +Confirm `Pay All` creates time tickets using the commission rate, not a flat hourly rate. + +### Steps +1. Use a converted repair order that has: +2. At least 1 labor line assigned to `Commission Team Test`. +3. Known labor sale rates on the job. +4. No existing time tickets for the same employee and labor type. +5. Open that repair order. +6. Go to the labor/payroll area where the `Labor Allocations` card is visible. +7. Write down the following before you click anything: +8. The labor type on the line. +9. The sold labor rate for that labor type. +10. The hours on that line. +11. The commission % you entered for that labor type on the team. +12. Click `Pay All`. +13. Wait for the success message. +14. Look at the `Time Tickets` list on the same screen. +15. Find the new ticket created for that employee. + +### Expected Result +- The system should show `All hours paid out successfully.` +- A new time ticket should appear. +- The ticket rate should equal: +- `sale rate x commission %` +- Example: if Body sale rate is `$100.00` and commission is `40%`, the ticket rate should be `$40.00`. +- The productive hours should match the assigned labor hours for that employee. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 9: Different Labor Types Use Different Commission Rates +### Goal +Confirm the feature uses the correct commission % for each labor type. + +### Steps +1. Use a job that has at least: +2. One Body labor line. +3. One Refinish labor line. +4. Make sure both lines are assigned to `Commission Team Test`. +5. Confirm your team is set up like this: +6. Body = `40%` +7. Refinish = `30%` +8. Open the job's `Labor Allocations` area. +9. Click `Pay All`. +10. Review the new time tickets that are created. + +### Expected Result +- The Body ticket should use the Body commission %. +- The Refinish ticket should use the Refinish commission %. +- Example: +- If Body sale rate is `$100.00`, Body payout rate should be `$40.00`. +- If Refinish sale rate is `$120.00`, Refinish payout rate should be `$36.00`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 10: Mixed Team With Commission and Hourly Members +### Goal +Confirm one team can contain both commission and hourly members, and each person is paid correctly. + +### Steps +1. Open `Manage my Shop` > `Employee Teams`. +2. Open `Commission Team Test`. +3. Edit the first team member: +4. Keep Employee 1 as `Commission %`. +5. Change `Allocation %` to `60`. +6. Make sure Body commission is still `40`. +7. Add a second team member. +8. Choose a different active employee. +9. Set `Allocation %` to `40`. +10. Set `Payout Method` to `Hourly`. +11. Enter an hourly rate for each required labor type. +12. For Body, use `$25.00`. +13. Fill in the other required hourly boxes with valid values. +14. Make sure the total allocation shows `100%`. +15. Click `Save`. +16. Assign this team to a Body line with `10.0` hours. +17. Click `Pay All`. +18. Review the new time tickets. + +### Expected Result +- Employee 1 should receive `60%` of the hours at the commission-derived rate. +- Employee 2 should receive `40%` of the hours at the hourly rate you entered. +- Example with a 10-hour Body line and `$100.00` sale rate: +- Employee 1 should get `6.0` hours at `$40.00`. +- Employee 2 should get `4.0` hours at `$25.00`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 11: Pay All Only Adds the Remaining Hours +### Goal +Confirm `Pay All` does not duplicate hours that were already paid. + +### Steps +1. Use a job with one Body line assigned to `Commission Team Test`. +2. Make sure the line has `10.0` hours. +3. In the `Time Tickets` card, click `Enter New Time Ticket`. +4. Create a manual time ticket for the same employee and the same labor type. +5. Enter `4.0` productive hours. +6. Save the manual time ticket. +7. Go back to the `Labor Allocations` card. +8. Click `Pay All`. +9. Review the new ticket that is created. + +### Expected Result +- The system should only create the remaining unpaid hours. +- In this example, it should add `6.0` hours, not `10.0`. +- The payout rate should still use the current commission-based rate. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 12: Unassigned Labor Lines Should Block Automatic Payout +### Goal +Confirm `Pay All` does not silently pay lines that do not have a team assigned. + +### Steps +1. Open a converted repair order with at least 2 labor lines. +2. Assign `Commission Team Test` to one line. +3. Leave the second labor line with no team assigned. +4. Go to the `Labor Allocations` card. +5. Click `Pay All`. + +### Expected Result +- The system should not quietly pay everything. +- You should see an error telling you that not all hours have been assigned. +- The unassigned line should still need manual attention. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 13: Flag Hours Preview Shows the Correct Payout Method +### Goal +If your shop uses task presets, confirm the preview shows `Commission` for commission-based tickets. + +### Steps +1. Open a converted repair order. +2. Go to the `Time Tickets` card. +3. Click `Flag Hours`. +4. Choose a task preset. +5. Wait for the preview table to load. +6. Review the `Payout Method` column in the preview. +7. If the preview includes more than one employee, review each row. + +### Expected Result +- The preview table should load without error. +- Rows for commission-based employees should show `Commission`. +- Rows for hourly employees should show `Hourly`. +- If there are unassigned hours, a warning should appear. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Quick Regression Checklist +- [ ] I can create a commission-based team. +- [ ] Allocation must total exactly 100%. +- [ ] The same employee cannot be added twice to one team. +- [ ] Inactive teams do not appear for normal assignment. +- [ ] A team can be assigned to job lines from the `Team` column. +- [ ] `Pay All` creates commission-based tickets correctly. +- [ ] Different labor types use different commission percentages. +- [ ] Mixed commission and hourly teams calculate correctly. +- [ ] `Pay All` only creates the remaining unpaid hours. +- [ ] Unassigned labor lines stop automatic payout. +- [ ] `Flag Hours` preview shows the correct payout method. + +## Tester Sign-Off +- Tester name: +- Test date: +- Environment: +- Overall result: +- Follow-up issues found: diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 3f8a6fc71..6054686fd 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -1,7 +1,33 @@ -This will connect to your dockers local stack session and render the email in HTML. +This app connects to your Docker LocalStack SES endpoint and gives you a local inbox-style viewer +for generated emails. + +```shell +npm start +``` + +Or: ```shell node index.js ``` -http://localhost:3334 +Open: http://localhost:3334 + +Features: + +- Manual refresh +- Live refresh with adjustable polling interval +- Search across subject, addresses, preview text, and attachment names +- Expand/collapse all messages +- Rendered HTML, plain-text, and raw MIME views +- Copy raw MIME source +- New-message highlighting plus fetch timing and parse-error stats + +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 +``` diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 1dd17f779..15b7736a9 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -1,96 +1,1011 @@ -// index.js - import express from "express"; import fetch from "node-fetch"; import { simpleParser } from "mailparser"; const app = express(); -const PORT = 3334; -app.get("/", async (req, res) => { +const PORT = Number(process.env.PORT || 3334); +const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; +const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); +const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); + +app.use((req, res, next) => { + res.set("Cache-Control", "no-store"); + next(); +}); + +app.get("/", (req, res) => { + res.type("html").send(renderHtml()); +}); + +app.get("/app.js", (req, res) => { + res.type("application/javascript").send(`(${clientApp.toString()})(${JSON.stringify(getClientConfig())});`); +}); + +app.get("/health", (req, res) => { + res.json({ + ok: true, + endpoint: SES_ENDPOINT, + port: PORT, + defaultRefreshMs: DEFAULT_REFRESH_MS + }); +}); + +app.get("/api/messages", async (req, res) => { try { - const response = await fetch("http://localhost:4566/_aws/ses"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - const data = await response.json(); - const messagesHtml = await parseMessages(data.messages); - res.send(renderHtml(messagesHtml)); + res.json(await loadMessages()); } catch (error) { console.error("Error fetching messages:", error); - res.status(500).send("Error fetching messages"); + res.status(502).json({ + error: "Unable to fetch messages from LocalStack SES", + details: error.message, + endpoint: SES_ENDPOINT + }); } }); -async function parseMessages(messages) { - const parsedMessages = await Promise.all( - messages.map(async (message, index) => { - try { - const parsed = await simpleParser(message.RawData); - return ` -
-
-
Message ${index + 1}
-
From: ${message.Source}
-
To: ${parsed.to.text || "No To Address"}
-
Subject: ${parsed.subject || "No Subject"}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
-
${parsed.html || parsed.textAsHtml || "No HTML content available"}
-
- `; - } catch (error) { - console.error("Error parsing email:", error); - return ` -
-
Message ${index + 1}
-
From: ${message.Source}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
Error parsing email content
-
- `; - } - }) - ); - return parsedMessages.join(""); +app.get("/api/messages/:id/raw", async (req, res) => { + try { + const messages = await fetchSesMessages(); + const message = messages.find((candidate) => resolveMessageId(candidate) === req.params.id); + + if (!message) { + res.status(404).type("text/plain").send("Message not found"); + return; + } + + res.type("text/plain").send(message.RawData || ""); + } catch (error) { + console.error("Error fetching raw message:", error); + res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`); + } +}); + +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 + }; } -function renderHtml(messagesHtml) { - return ` - - - - - - Email Messages Viewer - - - - -
-

Email Messages Viewer

-
${messagesHtml}
+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 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) => ({ + filename: attachment.filename || "Unnamed attachment", + 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 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 buildRenderedHtml(html) { + if (!html) { + return ""; + } + + const value = String(html); + const hasDocument = /]/i.test(value) || / + + + + + + + + ${value} +`; +} + +function stripTags(value) { + return String(value || "") + .replace(//gi, " ") + .replace(//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(", "); +} + +function getClientConfig() { + return { + defaultRefreshMs: DEFAULT_REFRESH_MS, + endpoint: SES_ENDPOINT + }; +} + +function renderHtml() { + return ` + + + + + LocalStack Email Viewer + + + +
+
+
+

LocalStack SES

+

Email Viewer

+

A faster local inbox for generated emails with live refresh, manual refresh, search, and raw MIME inspection.

- - +
+
+ + + +
+
+ + +
+
+ + + Waiting for first refresh... +
+
+
+ +
+
Total00 visible
+
New0New since last refresh
+
NewestNo messagesNot refreshed yet
+
FetchIdleEndpoint: ${escapeHtml(SES_ENDPOINT)}
+
+ + + +
+
+ + + +`; +} + +function renderStyles() { + return ` + :root{--panel:rgba(255,255,255,.86);--panel-strong:#fff;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--accent:#cf6d3c;--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 18px 48px rgba(35,43,53,.12);} + *{box-sizing:border-box} + html,body{margin:0;min-height:100%} + body{background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.14),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:16px/1.5 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif} + button,input,select,textarea{font:inherit} + button{cursor:pointer} + .page{max-width:1440px;margin:0 auto;padding:24px} + .hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(320px,.9fr);gap:24px;margin-bottom:24px} + .hero-copy,.hero-controls,.stat,.card{background:var(--panel);backdrop-filter:blur(16px);border:1px solid var(--line);box-shadow:var(--shadow)} + .hero-copy,.hero-controls{border-radius:24px;padding:24px} + .eyebrow{margin:0 0 8px;color:var(--accent);font-size:.8rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} + h1{margin:0;font-size:clamp(2.2rem,5vw,4rem);line-height:.95;letter-spacing:-.05em} + .lede{margin:16px 0 0;max-width:60ch;color:var(--muted)} + .hero-controls{display:grid;gap:12px} + .row{display:flex;flex-wrap:wrap;gap:12px;align-items:center} + .primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .primary,.ghost{min-height:44px;padding:0 18px;font-weight:700} + .mini,.tab{min-height:34px;padding:0 12px;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:var(--line);color:var(--ink)} + .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} + .chip{display:inline-flex;align-items:center;gap:10px;min-height:44px;padding:0 16px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600} + .chip input{margin:0;accent-color:var(--accent)} + .chip select{border:none;background:transparent;outline:none;color:var(--ink)} + .search{flex:1 1 320px;min-height:48px;padding:0 16px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} + .status{display:inline-flex;align-items:center;min-height:40px;padding:0 14px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);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:16px;margin-bottom:18px} + .stat{border-radius:18px;padding:18px} + .stat span{display:block;margin-bottom:10px;color:var(--muted);font-size:.82rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .stat strong{display:block;font-size:clamp(2rem,4vw,2.6rem);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:10px;color:var(--muted);font-size:.95rem} + .banner,.empty{margin:0 0 18px;padding:16px 18px;border-radius:16px;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:18px} + .card{overflow:hidden;border-radius:18px} + .card.new{border-color:rgba(31,143,101,.28);box-shadow:var(--shadow),0 0 0 1px rgba(31,143,101,.12)} + .summary{list-style:none;display:grid;gap:12px;padding:20px;cursor:pointer} + .summary::-webkit-details-marker{display:none} + .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:10px;align-items:center} + .top{justify-content:space-between} + .head{min-width:0;flex:1 1 320px} + .head h2{margin:0;font-size:clamp(1.1rem,2vw,1.45rem);line-height:1.2;letter-spacing:-.03em;word-break:break-word} + .meta{margin:6px 0 0;color:var(--muted);font-size:.95rem;word-break:break-word} + .time,.tag{display:inline-flex;align-items:center;min-height:28px;padding:0 12px;border-radius:999px;font-size:.84rem;font-weight:700} + .time{background:rgba(31,41,51,.06)} + .tag{background:rgba(31,41,51,.06);color:var(--muted)} + .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} + .body{display:grid;gap:16px;padding:18px 20px 20px;border-top:1px solid var(--line);background:var(--panel-strong)} + .toolbar{justify-content:space-between;align-items:flex-start} + .tabs{display:inline-flex;gap:6px;padding:6px;border-radius:999px;background:rgba(31,41,51,.05)} + .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} + .metaCard{padding:14px 16px;border-radius:14px;background:rgba(31,41,51,.035);border:1px solid rgba(31,41,51,.08)} + .metaCard dt{margin:0 0 6px;color:var(--muted);font-size:.8rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .metaCard dd{margin:0;word-break:break-word} + .attachments{gap:8px} + .attachment{padding:9px 12px;border-radius:12px;background:rgba(255,255,255,.72);border:1px solid var(--line);font-size:.92rem} + .panel{overflow:hidden;border-radius:14px;border:1px solid var(--line);background:#fff} + iframe{width:100%;min-height:720px;border:none;background:#fff} + pre{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:13px/1.55 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} + .placeholder,.inlineError{padding:16px} + .inlineError{color:var(--bad)} + @media (max-width:1080px){.hero{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media (max-width:720px){.page{padding:16px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:560px}} `; } +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function clientApp(config) { + const state = { + messages: [], + filtered: [], + search: "", + auto: true, + interval: config.defaultRefreshMs, + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + parseErrors: 0, + newest: "", + newIds: new Set(), + knownIds: new Set(), + openIds: new Set(), + views: {}, + raw: {} + }; + + const el = { + refreshButton: document.getElementById("refreshButton"), + autoToggle: document.getElementById("autoToggle"), + intervalSelect: document.getElementById("intervalSelect"), + searchInput: document.getElementById("searchInput"), + clearSearchButton: document.getElementById("clearSearchButton"), + expandAllButton: document.getElementById("expandAllButton"), + collapseAllButton: document.getElementById("collapseAllButton"), + statusChip: document.getElementById("statusChip"), + totalStat: document.getElementById("totalStat"), + visibleStat: document.getElementById("visibleStat"), + newStat: document.getElementById("newStat"), + newestStat: document.getElementById("newestStat"), + updatedStat: document.getElementById("updatedStat"), + fetchStat: document.getElementById("fetchStat"), + fetchDetail: document.getElementById("fetchDetail"), + banner: document.getElementById("banner"), + empty: document.getElementById("empty"), + list: document.getElementById("list") + }; + + el.intervalSelect.value = String(config.defaultRefreshMs); + wire(); + renderAll(); + refreshMessages("initial"); + window.setInterval(renderLiveClock, 1000); + + function wire() { + el.refreshButton.addEventListener("click", () => refreshMessages("manual")); + + el.autoToggle.addEventListener("change", () => { + state.auto = el.autoToggle.checked; + scheduleRefresh(); + renderStatus(); + }); + + el.intervalSelect.addEventListener("change", () => { + state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; + scheduleRefresh(); + renderStatus(); + }); + + el.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + applyFilter(); + }); + + el.clearSearchButton.addEventListener("click", () => { + state.search = ""; + el.searchInput.value = ""; + applyFilter(); + }); + + el.expandAllButton.addEventListener("click", () => { + state.filtered.forEach((message) => state.openIds.add(message.id)); + renderList(); + }); + + el.collapseAllButton.addEventListener("click", () => { + state.openIds.clear(); + renderList(); + }); + + el.list.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const message = getMessage(id); + + if (!message) { + return; + } + + if (button.dataset.action === "view") { + state.views[id] = button.dataset.view; + renderList(); + return; + } + + if (button.dataset.action === "load-raw") { + await loadRaw(id); + renderList(); + return; + } + + if (button.dataset.action === "copy-raw") { + const raw = await loadRaw(id); + + if (raw) { + await copyText(raw); + setStatus("Raw message copied to the clipboard.", "ok"); + } + } + }); + + document.addEventListener("visibilitychange", () => { + window.clearTimeout(state.timer); + + if (!document.hidden && state.auto) { + refreshMessages("visibility"); + } else { + renderStatus(); + } + }); + + window.addEventListener("keydown", (event) => { + const isField = + event.target instanceof HTMLElement && + (event.target.matches("input,textarea,select") || event.target.isContentEditable); + + if (!isField && event.key.toLowerCase() === "r") { + event.preventDefault(); + refreshMessages("keyboard"); + } + }); + } + + async function refreshMessages(source) { + if (state.loading) { + return; + } + + state.loading = true; + state.source = source; + state.error = ""; + renderStatus(); + renderFetch(); + + try { + const response = await fetch("/api/messages", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const nextIds = new Set(messages.map((message) => message.id)); + + state.newIds = state.updatedAt + ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) + : new Set(); + state.knownIds = nextIds; + state.messages = messages; + state.duration = payload.fetchDurationMs || 0; + state.parseErrors = payload.parseErrors || 0; + state.newest = payload.latestMessageTimestamp || ""; + state.updatedAt = Date.now(); + + pruneState(); + applyFilter(); + setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + state.error = error.message || "Unknown refresh error"; + setStatus(`Refresh failed: ${state.error}`, "bad"); + } finally { + state.loading = false; + scheduleRefresh(); + renderAll(); + } + } + + function pruneState() { + const ids = new Set(state.messages.map((message) => message.id)); + state.openIds = new Set([...state.openIds].filter((id) => ids.has(id))); + state.newIds = new Set([...state.newIds].filter((id) => ids.has(id))); + + Object.keys(state.views).forEach((id) => { + if (!ids.has(id)) { + delete state.views[id]; + } + }); + + Object.keys(state.raw).forEach((id) => { + if (!ids.has(id)) { + delete state.raw[id]; + } + }); + } + + function applyFilter() { + const search = state.search.trim().toLowerCase(); + state.filtered = !search + ? [...state.messages] + : state.messages.filter((message) => haystack(message).includes(search)); + renderAll(); + } + + function haystack(message) { + return [ + message.subject, + message.from, + message.to, + message.replyTo, + message.preview, + message.textContent, + message.region, + ...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function scheduleRefresh() { + window.clearTimeout(state.timer); + + if (!state.auto || document.hidden || state.loading) { + return; + } + + state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); + } + + function renderAll() { + renderStats(); + renderFetch(); + renderStatus(); + renderList(); + renderLiveClock(); + } + + function renderStats() { + el.totalStat.textContent = String(state.messages.length); + el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`; + el.newStat.textContent = String(state.newIds.size); + el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages"; + } + + function renderFetch() { + if (state.loading) { + el.fetchStat.textContent = "Refreshing..."; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + if (state.error) { + el.fetchStat.textContent = "Needs attention"; + el.fetchDetail.textContent = state.error; + return; + } + + if (!state.updatedAt) { + el.fetchStat.textContent = "Idle"; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + el.fetchStat.textContent = `${state.duration}ms`; + el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`; + } + + function renderStatus() { + el.statusChip.className = "status"; + + if (state.loading) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Refreshing messages..."; + return; + } + + if (state.error) { + el.statusChip.classList.add("bad"); + el.statusChip.textContent = `Refresh failed: ${state.error}`; + return; + } + + if (!state.auto) { + el.statusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!state.updatedAt) { + el.statusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000)); + el.statusChip.classList.add("ok"); + el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLiveClock() { + if (!state.updatedAt) { + el.updatedStat.textContent = "Not refreshed yet"; + return; + } + + el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`; + renderStatus(); + } + + function renderList() { + el.banner.hidden = !state.error; + el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : ""; + + if (!state.filtered.length) { + el.list.innerHTML = ""; + el.empty.hidden = false; + el.empty.textContent = state.messages.length + ? "No messages match the current search." + : "No emails yet. Send one through LocalStack SES and refresh."; + return; + } + + el.empty.hidden = true; + el.list.innerHTML = state.filtered.map(renderCard).join(""); + bindCardToggles(); + el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id))); + } + + function bindCardToggles() { + el.list.querySelectorAll(".card").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + state.openIds.add(id); + } else { + state.openIds.delete(id); + } + + hydrate(details, getMessage(id)); + }); + }); + } + + function renderCard(message) { + const open = state.openIds.has(message.id); + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + const tags = []; + + if (state.newIds.has(message.id)) { + tags.push('New'); + } + + if (message.attachmentCount) { + tags.push( + `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}` + ); + } + + tags.push(`${message.hasHtml ? "HTML" : "Text only"}`); + + if (message.parseError) { + tags.push('Parse issue'); + } + + return ` +
+ +
+
+

${escapeHtml(message.subject)}

+

${escapeHtml(message.from)} to ${escapeHtml(message.to)}

+
+ ${escapeHtml(formatDateTime(message.timestamp))} +
+
${tags.join("")}
+

${escapeHtml(message.preview)}

+
+
+
+
+ ${ + message.hasHtml + ? `` + : "" + } + + +
+
+ +
+
+ +
+ ${metaCard("From", message.from)} + ${metaCard("To", message.to)} + ${metaCard("Reply-To", message.replyTo || "None")} + ${metaCard("Sent", formatDateTime(message.timestamp))} + ${metaCard("Region", message.region || "Unknown region")} + ${metaCard("LocalStack Id", message.id)} + ${metaCard("Message-Id", message.messageId || "Not available")} + ${metaCard("Raw size", formatBytes(message.rawSizeBytes))} + ${message.parseError ? metaCard("Parse error", message.parseError) : ""} +
+ + ${ + message.attachments?.length + ? `
${message.attachments + .map((attachment) => { + const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; + return `${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; + }) + .join("")}
` + : "" + } + +
${renderPanel(message, view)}
+
+
+ `; + } + + function renderPanel(message, view) { + if (view === "rendered" && message.hasHtml) { + return ``; + } + + if (view === "raw") { + const raw = state.raw[message.id]; + + if (!raw) { + return `
Raw MIME source is loaded on demand.
`; + } + + if (raw.status === "loading") { + return '
Loading raw source...
'; + } + + if (raw.status === "error") { + return `
Unable to load raw source: ${escapeHtml(raw.error)}
`; + } + + return `
${escapeHtml(raw.value)}
`; + } + + return `
${escapeHtml(message.textContent || "No plain-text content available for this message.")}
`; + } + + function metaCard(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; + } + + function hydrate(details, message) { + if (!details || !details.open || !message) { + return; + } + + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + + if (view !== "rendered" || !message.hasHtml) { + return; + } + + const iframe = details.querySelector("[data-frame]"); + + if (iframe) { + iframe.referrerPolicy = "no-referrer"; + iframe.sandbox = ""; + iframe.srcdoc = message.renderedHtml || ""; + } + } + + function getMessage(id) { + return state.messages.find((message) => message.id === id); + } + + async function loadRaw(id) { + if (state.raw[id]?.status === "loaded") { + return state.raw[id].value; + } + + if (state.raw[id]?.status === "loading") { + return null; + } + + state.raw[id] = { status: "loading" }; + renderList(); + + try { + const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" }); + + if (!response.ok) { + throw new Error((await response.text()) || `Request failed with ${response.status}`); + } + + const value = await response.text(); + state.raw[id] = { status: "loaded", value }; + return value; + } catch (error) { + state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" }; + setStatus("Could not load the raw message source.", "bad"); + return null; + } finally { + renderList(); + } + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + } + + function setStatus(message, tone) { + el.statusChip.className = "status"; + + if (tone) { + el.statusChip.classList.add(tone); + } + + el.statusChip.textContent = message; + } + + function formatDateTime(value) { + if (!value) { + return "Unknown time"; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "Unknown time" + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + } + + function formatRelative(timestampMs) { + const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000)); + + if (seconds < 60) { + return `${seconds}s ago`; + } + + const minutes = Math.round(seconds / 60); + + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.round(minutes / 60); + + if (hours < 24) { + return `${hours}h ago`; + } + + return `${Math.round(hours / 24)}d ago`; + } + + function formatBytes(value) { + if (!value) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let index = 0; + + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + + return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + async function safeJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } +} + app.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); + console.log(`Local email viewer is running on http://localhost:${PORT}`); + console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); }); diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index 5f553cf17..a6b522e0e 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -4,12 +4,13 @@ "main": "index.js", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node index.js", + "check": "node --check index.js" }, "keywords": [], "author": "", "license": "ISC", - "description": "", + "description": "LocalStack SES email viewer for inspecting local outbound mail", "dependencies": { "express": "^5.1.0", "mailparser": "^3.7.4", diff --git a/client/src/components/_test/test.page.jsx b/client/src/components/_test/test.page.jsx index 87ef90e95..a50a7d894 100644 --- a/client/src/components/_test/test.page.jsx +++ b/client/src/components/_test/test.page.jsx @@ -1,7 +1,11 @@ -import { Button } from "antd"; +import { Button, Card, Divider, Form, Space, Typography } from "antd"; import { connect } from "react-redux"; +import queryString from "query-string"; +import { useLocation } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import { setModalContext } from "../../redux/modals/modals.actions"; +import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx"; +import { TimeTicketTaskModalComponent } from "../time-ticket-task-modal/time-ticket-task-modal.component.jsx"; const mapStateToProps = createStructuredSelector({}); @@ -9,8 +13,109 @@ const mapDispatchToProps = (dispatch) => ({ setRefundPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "refund_payment" })) }); +const commissionCutFixture = { + bodyshop: { + features: { + timetickets: true + }, + employees: [ + { id: "emp-1", first_name: "Avery", last_name: "Johnson" }, + { id: "emp-2", first_name: "Morgan", last_name: "Lee" } + ], + md_tasks_presets: { + presets: [ + { + name: "Body Prep", + percent: 50, + hourstype: ["LAA", "LAB"], + nextstatus: "In Progress" + } + ] + } + }, + jobId: "fixture-job-1", + joblines: [ + { + id: "line-1", + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + } + ], + previewValues: { + task: "Body Prep", + timetickets: [ + { + employeeid: "emp-1", + cost_center: "Body", + ciecacode: "LAA", + productivehrs: 2, + rate: 40, + payoutamount: 80, + payout_context: { + payout_method: "commission" + } + }, + { + employeeid: "emp-2", + cost_center: "Refinish", + ciecacode: "LAB", + productivehrs: 1, + rate: 28, + payoutamount: 28, + payout_context: { + payout_method: "hourly" + } + } + ] + } +}; + +function CommissionCutHarness() { + const [form] = Form.useForm(); + + return ( + + Commission Cut Test Harness + + This fixture keeps commission-cut browser checks stable by rendering representative payroll and preview UI with + local data. + + + {}} + /> + + + +
+ + +
+
+ ); +} + function Test({ setRefundPaymentContext, refundPaymentModal }) { + const search = queryString.parse(useLocation().search); console.log("refundPaymentModal", refundPaymentModal); + + if (search.fixture === "commission-cut") { + return ; + } + return (
+ + ); + + 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" + }); + }); +}); diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.test.jsx b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.test.jsx new file mode 100644 index 000000000..82caf248d --- /dev/null +++ b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.test.jsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { PayrollLaborAllocationsTable } from "./labor-allocations-table.payroll.component.jsx"; + +const notification = { + success: vi.fn(), + error: vi.fn() +}; + +vi.mock("axios", () => ({ + default: { + post: vi.fn() + } +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "jobs.labels.laborallocations": "Labor Allocations", + "timetickets.actions.payall": "Pay All", + "general.labels.totals": "Totals", + "jobs.labels.outstandinghours": "Outstanding hours remain." + }; + + if (key === "timetickets.successes.payall") { + return "All hours paid out successfully."; + } + + if (key === "timetickets.errors.payall") { + return `Error flagging hours. ${values.error || ""}`.trim(); + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({ + useNotification: () => notification +})); + +vi.mock("../responsive-table/responsive-table.component", () => { + function ResponsiveTable({ dataSource = [], summary }) { + return ( +
+
{`rows:${dataSource.length}`}
+ {summary ?
{summary()}
: null} +
+ ); + } + + ResponsiveTable.Summary = { + Row: ({ children }) =>
{children}
, + Cell: ({ children }) =>
{children}
+ }; + + return { + default: ResponsiveTable + }; +}); + +vi.mock("../feature-wrapper/feature-wrapper.component", () => ({ + HasFeatureAccess: () => true +})); + +vi.mock("../upsell/upsell.component", () => ({ + default: () =>
Upsell
, + 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( + + ); + + 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( + + ); + + 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." + }); + }); + }); +}); diff --git a/client/src/components/shop-teams/shop-employee-teams.form.component.jsx b/client/src/components/shop-teams/shop-employee-teams.form.component.jsx index 0b92fa69c..7ae1000c0 100644 --- a/client/src/components/shop-teams/shop-employee-teams.form.component.jsx +++ b/client/src/components/shop-teams/shop-employee-teams.form.component.jsx @@ -36,14 +36,20 @@ import { } from "../../graphql/employee_teams.queries"; import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { + LABOR_TYPES, + getSplitTotal, + hasExactSplitTotal, + normalizeEmployeeTeam, + normalizeTeamMember, + validateEmployeeTeamMembers +} from "./shop-employee-teams.form.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); const mapDispatchToProps = () => ({}); -const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"]; - const PAYOUT_METHOD_OPTIONS = [ { labelKey: "employee_teams.options.hourly", value: "hourly" }, { labelKey: "employee_teams.options.commission_percentage", value: "commission" } @@ -57,23 +63,6 @@ const TEAM_MEMBER_PRIMARY_FIELD_COLS = { 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) => { @@ -170,32 +159,11 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) { }; const handleFinish = async ({ employee_team_members = [], ...values }) => { - const normalizedTeamMembers = employee_team_members.map((teamMember) => { - const nextTeamMember = normalizeTeamMember({ ...teamMember }); - delete nextTeamMember.__typename; - return nextTeamMember; - }); + const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members); - if (normalizedTeamMembers.length === 0) { + if (errorKey) { notification.error({ - 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") + title: t(errorKey) }); return; } diff --git a/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx b/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx new file mode 100644 index 000000000..c23a35615 --- /dev/null +++ b/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx @@ -0,0 +1,247 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { INSERT_EMPLOYEE_TEAM, UPDATE_EMPLOYEE_TEAM } from "../../graphql/employee_teams.queries"; +import { LABOR_TYPES } from "./shop-employee-teams.form.utils.js"; +import { ShopEmployeeTeamsFormComponent } from "./shop-employee-teams.form.component.jsx"; + +const insertEmployeeTeamMock = vi.fn(); +const updateEmployeeTeamMock = vi.fn(); +const useQueryMock = vi.fn(); +const useMutationMock = vi.fn(); +const navigateMock = vi.fn(); +const notification = { + error: vi.fn(), + success: vi.fn() +}; + +vi.mock("@apollo/client/react", () => ({ + useQuery: (...args) => useQueryMock(...args), + useMutation: (...args) => useMutationMock(...args) +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ + search: "?employeeTeamId=new" + }), + useNavigate: () => navigateMock +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "employee_teams.fields.name": "Team Name", + "employee_teams.fields.active": "Active", + "employee_teams.fields.max_load": "Max Load", + "employee_teams.fields.employeeid": "Employee", + "employee_teams.fields.allocation_percentage": "Allocation %", + "employee_teams.fields.payout_method": "Payout Method", + "employee_teams.fields.allocation": "Allocation", + "employee_teams.fields.employeeid_label": "Employee", + "employee_teams.options.hourly": "Hourly", + "employee_teams.options.commission": "Commission", + "employee_teams.options.commission_percentage": "Commission", + "employee_teams.actions.newmember": "New Team Member", + "employee_teams.errors.minimum_one_member": "Add at least one team member.", + "employee_teams.errors.duplicate_member": "Team members must be unique.", + "employee_teams.errors.allocation_total_exact": "Allocation must total exactly 100%.", + "general.actions.save": "Save", + "employees.successes.save": "Saved" + }; + + if (key === "employee_teams.labels.allocation_total") { + return `Allocation Total: ${values.total}%`; + } + + if (key.startsWith("joblines.fields.lbr_types.")) { + return key.split(".").pop(); + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({ + useNotification: () => notification +})); + +vi.mock("../../firebase/firebase.utils", () => ({ + logImEXEvent: vi.fn() +})); + +vi.mock("../employee-search-select/employee-search-select.component", () => ({ + default: ({ id, value, onChange, options = [] }) => ( + + ) +})); + +vi.mock("../form-items-formatted/currency-form-item.component", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value === "" ? null : Number(event.target.value))} + /> + ) +})); + +vi.mock("../layout-form-row/layout-form-row.component", () => ({ + default: ({ title, extra, children }) => ( +
+ {title} + {extra} + {children} +
+ ) +})); + +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(); + + 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(); + + fireEvent.change(screen.getByRole("textbox", { name: "Team Name" }), { + target: { value: "Commission Crew" } + }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Max Load" }), { + target: { value: "8" } + }); + + addBaseTeamMember({ + employeeId: "emp-1", + percentage: 100, + rate: 27.5 + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(insertEmployeeTeamMock).toHaveBeenCalledWith({ + variables: { + employeeTeam: { + name: "Commission Crew", + max_load: 8, + employee_team_members: { + data: [ + { + employeeid: "emp-1", + percentage: 100, + payout_method: "hourly", + labor_rates: Object.fromEntries(LABOR_TYPES.map((laborType) => [laborType, 27.5])), + commission_rates: {} + } + ] + }, + bodyshopid: "shop-1" + } + }, + refetchQueries: ["QUERY_TEAMS"] + }); + }); + + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeTeamId=team-1" + }); + }); +}); diff --git a/client/src/components/shop-teams/shop-employee-teams.form.utils.js b/client/src/components/shop-teams/shop-employee-teams.form.utils.js new file mode 100644 index 000000000..b56352377 --- /dev/null +++ b/client/src/components/shop-teams/shop-employee-teams.form.utils.js @@ -0,0 +1,70 @@ +export const LABOR_TYPES = [ + "LAA", + "LAB", + "LAD", + "LAE", + "LAF", + "LAG", + "LAM", + "LAR", + "LAS", + "LAU", + "LA1", + "LA2", + "LA3", + "LA4" +]; + +export const normalizeTeamMember = (teamMember = {}) => ({ + ...teamMember, + payout_method: teamMember.payout_method || "hourly", + labor_rates: teamMember.labor_rates || {}, + commission_rates: teamMember.commission_rates || {} +}); + +export const normalizeEmployeeTeam = (employeeTeam = {}) => ({ + ...employeeTeam, + employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember) +}); + +export const getSplitTotal = (teamMembers = []) => + teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0); + +export const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001; + +export const validateEmployeeTeamMembers = (employeeTeamMembers = []) => { + const normalizedTeamMembers = employeeTeamMembers.map((teamMember) => { + const nextTeamMember = normalizeTeamMember({ ...teamMember }); + delete nextTeamMember.__typename; + return nextTeamMember; + }); + + if (normalizedTeamMembers.length === 0) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.minimum_one_member" + }; + } + + const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean); + const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index); + + if (duplicateEmployeeIds.length > 0) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.duplicate_member" + }; + } + + if (!hasExactSplitTotal(normalizedTeamMembers)) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.allocation_total_exact" + }; + } + + return { + normalizedTeamMembers, + errorKey: null + }; +}; diff --git a/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js b/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js new file mode 100644 index 000000000..e9a03d2f0 --- /dev/null +++ b/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + getSplitTotal, + hasExactSplitTotal, + normalizeTeamMember, + validateEmployeeTeamMembers +} from "./shop-employee-teams.form.utils.js"; + +describe("shop employee team form utilities", () => { + it("normalizes missing payout defaults for a team member", () => { + expect( + normalizeTeamMember({ + employeeid: "emp-1", + percentage: 100 + }) + ).toEqual({ + employeeid: "emp-1", + percentage: 100, + payout_method: "hourly", + labor_rates: {}, + commission_rates: {} + }); + }); + + it("returns a minimum-member validation error when no team members are provided", () => { + expect(validateEmployeeTeamMembers([])).toEqual({ + normalizedTeamMembers: [], + errorKey: "employee_teams.errors.minimum_one_member" + }); + }); + + it("rejects duplicate employees in the same team", () => { + const result = validateEmployeeTeamMembers([ + { employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 25 } }, + { employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 30 } } + ]); + + expect(result.errorKey).toBe("employee_teams.errors.duplicate_member"); + }); + + it("rejects team allocations that do not add up to exactly one hundred percent", () => { + const result = validateEmployeeTeamMembers([ + { employeeid: "emp-1", percentage: 60, labor_rates: { LAA: 25 } }, + { employeeid: "emp-2", percentage: 30, labor_rates: { LAA: 30 } } + ]); + + expect(getSplitTotal(result.normalizedTeamMembers)).toBe(90); + expect(hasExactSplitTotal(result.normalizedTeamMembers)).toBe(false); + expect(result.errorKey).toBe("employee_teams.errors.allocation_total_exact"); + }); + + it("accepts a valid mixed hourly and commission team and strips graph metadata", () => { + const result = validateEmployeeTeamMembers([ + { + __typename: "employee_team_members", + employeeid: "emp-1", + percentage: 40, + labor_rates: { LAA: 28.5 } + }, + { + employeeid: "emp-2", + percentage: 60, + payout_method: "commission", + commission_rates: { LAA: 35 } + } + ]); + + expect(result.errorKey).toBeNull(); + expect(result.normalizedTeamMembers).toEqual([ + { + employeeid: "emp-1", + percentage: 40, + payout_method: "hourly", + labor_rates: { LAA: 28.5 }, + commission_rates: {} + }, + { + employeeid: "emp-2", + percentage: 60, + payout_method: "commission", + labor_rates: {}, + commission_rates: { LAA: 35 } + } + ]); + }); +}); diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx new file mode 100644 index 000000000..0b3a36c34 --- /dev/null +++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import { Form } from "antd"; +import { describe, expect, it, vi } from "vitest"; +import { TimeTicketTaskModalComponent } from "./time-ticket-task-modal.component.jsx"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "timetickets.fields.ro_number": "RO Number", + "timetickets.labels.task": "Task", + "bodyshop.fields.md_tasks_presets.percent": "Percent", + "bodyshop.fields.md_tasks_presets.hourstype": "Labor Types", + "bodyshop.fields.md_tasks_presets.nextstatus": "Next Status", + "timetickets.labels.claimtaskpreview": "Claim Task Preview", + "timetickets.fields.employee": "Employee", + "timetickets.fields.cost_center": "Cost Center", + "timetickets.fields.ciecacode": "Labor Type", + "timetickets.fields.productivehrs": "Hours", + "timetickets.fields.payout_method": "Payout Method", + "timetickets.fields.rate": "Rate", + "timetickets.fields.amount": "Amount", + "timetickets.labels.payout_methods.commission": "Commission", + "timetickets.labels.payout_methods.hourly": "Hourly", + "timetickets.labels.payrollclaimedtasks": "Payroll claimed tasks are ready.", + "tt_approvals.labels.approval_queue_in_use": "Approval queue is enabled." + }; + + if (key === "timetickets.validation.unassignedlines") { + return `${values.unassignedHours} hours remain unassigned.`; + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../form-items-formatted/read-only-form-item.component", () => ({ + default: ({ value }) => {value} +})); + +vi.mock("../job-search-select/job-search-select.component", () => ({ + default: () =>
Job Search
+})); + +function TestHarness({ unassignedHours = 0 }) { + const [form] = Form.useForm(); + + return ( +
+ + + ); +} + +describe("TimeTicketTaskModalComponent", () => { + it("shows preview payout methods for both commission and hourly tickets", () => { + render(); + + 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(); + + expect(screen.getByText("1.25 hours remain unassigned.")).toBeInTheDocument(); + }); +}); diff --git a/client/tests/e2e/commission-based-cut.e2e.js b/client/tests/e2e/commission-based-cut.e2e.js new file mode 100644 index 000000000..6202108d8 --- /dev/null +++ b/client/tests/e2e/commission-based-cut.e2e.js @@ -0,0 +1,140 @@ +/* eslint-disable */ + +import { expect, test } from "@playwright/test"; +import { acceptEulaIfPresent, login } from "./utils/login"; + +async function openCommissionCutHarness(page) { + await page.goto("/manage/_test?fixture=commission-cut"); + await acceptEulaIfPresent(page); + await expect(page.getByRole("heading", { name: "Commission Cut Test Harness" })).toBeVisible(); +} + +test.describe("Commission-based cut", () => { + test.skip(!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD, "Requires TEST_USERNAME and TEST_PASSWORD."); + + test("renders payout previews and completes Pay All from the commission-cut harness", async ({ page }) => { + let calculateLaborCalls = 0; + let payAllCalls = 0; + + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + calculateLaborCalls += 1; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + }, + { + employeeid: "emp-2", + mod_lbr_ty: "LAB", + expectedHours: 2, + claimedHours: 1 + } + ]) + }); + }); + + await page.route("**/payroll/payall", async (route) => { + payAllCalls += 1; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: "tt-1" }]) + }); + }); + + await openCommissionCutHarness(page); + await expect(page.getByText("Claim Task Preview")).toBeVisible(); + await expect(page.getByRole("cell", { name: "Commission" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "Hourly" })).toBeVisible(); + await expect( + page.getByText( + "There are currently 1.25 hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually." + ) + ).toBeVisible(); + + await expect(page.getByRole("button", { name: "Pay All" })).toBeVisible(); + await page.getByRole("button", { name: "Pay All" }).click(); + + await expect.poll(() => calculateLaborCalls).toBeGreaterThan(0); + await expect.poll(() => payAllCalls).toBe(1); + await expect(page.getByText("All hours paid out successfully.")).toBeVisible(); + }); + + test("shows the backend error when Pay All is rejected", async ({ page }) => { + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + } + ]) + }); + }); + + await page.route("**/payroll/payall", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: false, + error: "Not all hours have been assigned." + }) + }); + }); + + await openCommissionCutHarness(page); + await page.getByRole("button", { name: "Pay All" }).click(); + + await expect(page.getByText("Error flagging hours. Not all hours have been assigned.")).toBeVisible(); + }); + + test("shows a negative labor difference when previously claimed hours exceed the current expected hours", async ({ + page + }) => { + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 2, + claimedHours: 5 + } + ]) + }); + }); + + await openCommissionCutHarness(page); + + await expect(page.locator("strong").filter({ hasText: "-3" }).first()).toBeVisible(); + }); +}); diff --git a/client/tests/e2e/utils/login.js b/client/tests/e2e/utils/login.js index 8b6c2e9f8..9219890e7 100644 --- a/client/tests/e2e/utils/login.js +++ b/client/tests/e2e/utils/login.js @@ -1,5 +1,48 @@ import { expect } from "@playwright/test"; +const formatToday = () => { + const today = new Date(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const year = today.getFullYear(); + return `${month}/${day}/${year}`; +}; + +export async function acceptEulaIfPresent(page) { + const eulaDialog = page.getByRole("dialog", { name: "Terms and Conditions" }); + + const eulaVisible = + (await eulaDialog.isVisible().catch(() => false)) || + (await eulaDialog + .waitFor({ + state: "visible", + timeout: 5000 + }) + .then(() => true) + .catch(() => false)); + + if (!eulaVisible) { + return; + } + + const markdownCard = page.locator(".eula-markdown-card"); + await markdownCard.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + + await page.getByRole("textbox", { name: "First Name" }).fill("Codex"); + await page.getByRole("textbox", { name: "Last Name" }).fill("Tester"); + await page.getByRole("textbox", { name: "Legal Business Name" }).fill("Codex QA"); + await page.getByRole("textbox", { name: "Date Accepted" }).fill(formatToday()); + await page.getByRole("checkbox", { name: "I accept the terms and conditions of this agreement." }).check(); + + const acceptButton = page.getByRole("button", { name: "Accept EULA" }); + await expect(acceptButton).toBeEnabled({ timeout: 10000 }); + await acceptButton.click(); + await expect(eulaDialog).not.toBeVisible({ timeout: 10000 }); +} + export async function login(page, { email, password }) { // Navigate to the login page await page.goto("/"); // Adjust if your login route differs (e.g., '/login') @@ -16,6 +59,8 @@ export async function login(page, { email, password }) { // Wait for navigation or success indicator (e.g., redirect to /manage/) await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect + await acceptEulaIfPresent(page); + // Verify successful login (e.g., check for a dashboard element) await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your app’s post-login UI } diff --git a/client/tests/setup.js b/client/tests/setup.js index 8b4799a23..b544949b1 100644 --- a/client/tests/setup.js +++ b/client/tests/setup.js @@ -1,5 +1,45 @@ -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; +if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); +} + +if (!window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.IntersectionObserver) { + window.IntersectionObserver = class IntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.scrollTo) { + window.scrollTo = vi.fn(); +} + +if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = vi.fn(); +} + afterEach(() => cleanup()); diff --git a/server/payroll/payroll.test.js b/server/payroll/payroll.test.js index 3a0a105e8..f8b8dcb31 100644 --- a/server/payroll/payroll.test.js +++ b/server/payroll/payroll.test.js @@ -5,6 +5,7 @@ const logMock = vi.fn(); let payAllModule; let claimTaskModule; +let calculateTotalsModule; const buildBaseJob = (overrides = {}) => ({ id: "job-1", @@ -62,6 +63,7 @@ beforeEach(() => { mockRequire("../utils/logger", { log: logMock }); payAllModule = require("./pay-all"); claimTaskModule = require("./claim-task"); + calculateTotalsModule = require("./calculate-totals"); }); describe("payroll payout helpers", () => { @@ -174,6 +176,104 @@ describe("payroll payout helpers", () => { ) ).toThrow("Missing hourly payout rate for John Smith on labor type LAB."); }); + + it("supports commission boundary values of zero and one hundred percent", () => { + const zeroPercent = payAllModule.BuildPayoutDetails( + { + rate_laa: 123.45 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 0 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + const fullPercent = payAllModule.BuildPayoutDetails( + { + rate_laa: 123.45 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 100 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + expect(zeroPercent.effectiveRate).toBe(0); + expect(zeroPercent.payoutContext.cut_percent_applied).toBe(0); + expect(fullPercent.effectiveRate).toBe(123.45); + expect(fullPercent.payoutContext.cut_percent_applied).toBe(100); + }); + + it("throws a useful error when the sale rate for a commission labor type is missing", () => { + expect(() => + payAllModule.BuildPayoutDetails( + {}, + { + payout_method: "commission", + commission_rates: { + LAA: 35 + }, + employee: { + first_name: "Sam", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Missing sale rate rate_laa for labor type LAA."); + }); + + it("rejects commission percentages outside the allowed zero-to-one-hundred range", () => { + expect(() => + payAllModule.BuildPayoutDetails( + { + rate_laa: 100 + }, + { + payout_method: "commission", + commission_rates: { + LAA: -5 + }, + employee: { + first_name: "Alex", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); + + expect(() => + payAllModule.BuildPayoutDetails( + { + rate_laa: 100 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 105 + }, + employee: { + first_name: "Alex", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); + }); }); describe("payroll routes", () => { @@ -277,6 +377,344 @@ describe("payroll routes", () => { expect(res.json).toHaveBeenCalledWith(insertedTickets); }); + it("returns a validation failure when job lines still have unassigned hours", async () => { + const job = buildBaseJob({ + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 3.5, + assigned_team: null, + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + + await payAllModule.payall(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Not all hours have been assigned." + }); + }); + + it("creates separate pay-all tickets for mixed hourly and commission team members across labor types", async () => { + const job = buildBaseJob({ + rate_laa: 100, + rate_lab: 80, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body", + LAB: "Refinish" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 50, + payout_method: "hourly", + labor_rates: { + LAA: 30, + LAB: 25 + }, + employee: { + id: "emp-hourly", + first_name: "Hourly", + last_name: "Tech" + } + }, + { + percentage: 50, + payout_method: "commission", + commission_rates: { + LAA: 40, + LAB: 50 + }, + labor_rates: { + LAA: 0, + LAB: 0 + }, + employee: { + id: "emp-commission", + first_name: "Commission", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + }, + { + mod_lbr_ty: "LAB", + mod_lb_hrs: 2, + assigned_team: "team-1", + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 4 } }); + + await payAllModule.payall(req, res); + + expect(client.request).toHaveBeenCalledTimes(2); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(4); + expect(insertedTickets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + employeeid: "emp-hourly", + ciecacode: "LAA", + productivehrs: 2, + rate: 30, + cost_center: "Body", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 30 + }) + }), + expect.objectContaining({ + employeeid: "emp-hourly", + ciecacode: "LAB", + productivehrs: 1, + rate: 25, + cost_center: "Refinish", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 25 + }) + }), + expect.objectContaining({ + employeeid: "emp-commission", + ciecacode: "LAA", + productivehrs: 2, + rate: 40, + cost_center: "Body", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 100, + effective_rate: 40 + }) + }), + expect.objectContaining({ + employeeid: "emp-commission", + ciecacode: "LAB", + productivehrs: 1, + rate: 40, + cost_center: "Refinish", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 50, + source_labor_rate: 80, + effective_rate: 40 + }) + }) + ]) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("creates a negative pay-all adjustment at the current commission rate when the remaining expected hours drop below prior claimed hours", async () => { + const job = buildBaseJob({ + rate_laa: 120, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 6, + rate: 30, + payout_context: { + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 30 + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); + + await payAllModule.payall(req, res); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(1); + expect(insertedTickets[0]).toEqual( + expect.objectContaining({ + employeeid: "emp-1", + productivehrs: -2, + rate: 48, + ciecacode: "LAA", + memo: "Adjust flagged hours per assignment. (payroll@example.com)" + }) + ); + expect(insertedTickets[0].payout_context).toEqual( + expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 120, + effective_rate: 48, + generated_from: "payall", + used_ticket_fallback: false + }) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("uses the current commission sale rate for remaining hours when older commission tickets were created from a lower sale rate", async () => { + const job = buildBaseJob({ + rate_laa: 120, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 5, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 2, + rate: 40, + payout_context: { + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 100, + effective_rate: 40 + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); + + await payAllModule.payall(req, res); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(1); + expect(insertedTickets[0]).toEqual( + expect.objectContaining({ + employeeid: "emp-1", + productivehrs: 3, + rate: 48, + ciecacode: "LAA" + }) + ); + expect(insertedTickets[0].payout_context).toEqual( + expect.objectContaining({ + payout_method: "commission", + cut_percent_applied: 40, + source_labor_rate: 120, + effective_rate: 48, + generated_from: "payall", + used_ticket_fallback: false + }) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + it("rejects duplicate claim-task submissions for completed presets", async () => { const job = buildBaseJob({ completed_tasks: [{ name: "Disassembly" }], @@ -381,6 +819,132 @@ describe("payroll routes", () => { }); }); + it("returns commission-aware claim-task previews and reports unassigned hours", async () => { + const job = buildBaseJob({ + rate_laa: 120, + rate_lab: 80, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body", + LAB: "Refinish" + } + } + }, + md_tasks_presets: { + presets: [ + { + name: "Body Prep", + hourstype: ["LAA", "LAB"], + percent: 50, + nextstatus: "Prep", + memo: "Prep body work" + } + ] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 50, + payout_method: "hourly", + labor_rates: { + LAA: 30, + LAB: 25 + }, + employee: { + id: "emp-hourly", + first_name: "Hourly", + last_name: "Tech" + } + }, + { + percentage: 50, + payout_method: "commission", + commission_rates: { + LAA: 40, + LAB: 50 + }, + employee: { + id: "emp-commission", + first_name: "Commission", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + }, + { + mod_lbr_ty: "LAB", + mod_lb_hrs: 1.5, + assigned_team: null, + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ + job, + body: { + task: "Body Prep", + calculateOnly: true, + employee: { + name: "Payroll Manager" + } + } + }); + + await claimTaskModule.claimtask(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith({ + unassignedHours: 1.5, + ticketsToInsert: expect.arrayContaining([ + expect.objectContaining({ + task_name: "Body Prep", + employeeid: "emp-hourly", + productivehrs: 1, + rate: 30, + ciecacode: "LAA", + created_by: "Payroll Manager", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + generated_from: "claimtask", + task_name: "Body Prep" + }) + }), + expect.objectContaining({ + task_name: "Body Prep", + employeeid: "emp-commission", + productivehrs: 1, + rate: 48, + ciecacode: "LAA", + created_by: "Payroll Manager", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 120, + generated_from: "claimtask", + task_name: "Body Prep" + }) + }) + ]) + }); + }); + it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => { const job = buildBaseJob({ bodyshop: { @@ -462,4 +1026,78 @@ describe("payroll routes", () => { error: "Missing hourly payout rate for Missing Rate on labor type LAB." }); }); + + it("locks in the current enhanced-payroll behavior of ignoring lbr_adjustments when calculating labor totals", async () => { + const job = buildBaseJob({ + lbr_adjustments: { + LAA: 2.5 + }, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 1, + rate: 48, + payout_context: { + payout_method: "commission" + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + + await calculateTotalsModule.calculatelabor(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith([ + { + employeeid: "emp-1", + rate: 40, + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + } + ]); + }); });