From bc5f9f88d199b3d00bdba8e54e431be75902413a Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 17 Mar 2026 00:30:07 -0700 Subject: [PATCH 1/9] IO-3616 Production Phone# Deeplinking Signed-off-by: Allan Carr --- .../production-list-columns.data.jsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/src/components/production-list-columns/production-list-columns.data.jsx b/client/src/components/production-list-columns/production-list-columns.data.jsx index 44fb6bafa..f44bbb3cc 100644 --- a/client/src/components/production-list-columns/production-list-columns.data.jsx +++ b/client/src/components/production-list-columns/production-list-columns.data.jsx @@ -6,7 +6,7 @@ import { setModalContext } from "../../redux/modals/modals.actions"; import { store } from "../../redux/store"; import CurrencyFormatter from "../../utils/CurrencyFormatter"; import { TimeFormatter } from "../../utils/DateFormatter"; -import PhoneFormatter from "../../utils/PhoneFormatter"; +import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import { onlyUnique } from "../../utils/arrayHelper"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import { alphaSort, dateSort, statusSort } from "../../utils/sorters"; @@ -28,6 +28,7 @@ import ProductionListColumnCategory from "./production-list-columns.status.categ import ProductionListColumnStatus from "./production-list-columns.status.component"; import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; +import ChatOpenButton from "../chat-open-button/chat-open-button.component.jsx"; const getEmployeeName = (employeeId, employees) => { const employee = employees.find((e) => e.id === employeeId); @@ -271,14 +272,24 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo dataIndex: "ownr_ph1", key: "ownr_ph1", ellipsis: true, - render: (text, record) => {record.ownr_ph1} + render: (text, record) => + technician ? ( + {record.ownr_ph1} + ) : ( + + ) }, { title: i18n.t("jobs.fields.ownr_ph2"), dataIndex: "ownr_ph2", key: "ownr_ph2", ellipsis: true, - render: (text, record) => {record.ownr_ph2} + render: (text, record) => + technician ? ( + {record.ownr_ph2} + ) : ( + + ) }, { title: i18n.t("jobs.fields.specialcoveragepolicy"), From 73ba95a240c05f00219fe725cbcdf4cc662d9211 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 10:50:22 -0400 Subject: [PATCH 2/9] feature/IO-3587-Commision-Cut - rebuild from master-AIO --- .../read-only-form-item.component.jsx | 18 +- .../form-list-move-arrows.component.jsx | 4 +- ...or-allocations-table.payroll.component.jsx | 54 +- .../shop-info.task-presets.component.jsx | 55 +- .../shop-employee-teams.form.component.jsx | 513 +++++++++--------- .../time-ticket-task-modal.component.jsx | 88 ++- .../time-ticket-task-modal.container.jsx | 27 +- .../tt-approvals-list.component.jsx | 18 +- .../tt-approve-button.component.jsx | 18 +- client/src/graphql/bodyshop.queries.js | 4 + client/src/graphql/employee_teams.queries.js | 12 + client/src/graphql/timetickets.queries.js | 4 + client/src/graphql/tt-approvals.queries.js | 19 + client/src/translations/en_us/common.json | 27 +- client/src/translations/es/common.json | 27 +- client/src/translations/fr/common.json | 27 +- server/graphql-client/queries.js | 6 + server/payroll/calculate-totals.js | 66 +-- server/payroll/claim-task.js | 99 +++- server/payroll/pay-all.js | 501 ++++++++++------- server/payroll/payroll.test.js | 465 ++++++++++++++++ 21 files changed, 1473 insertions(+), 579 deletions(-) create mode 100644 server/payroll/payroll.test.js diff --git a/client/src/components/form-items-formatted/read-only-form-item.component.jsx b/client/src/components/form-items-formatted/read-only-form-item.component.jsx index 786d6a1a0..fc4490528 100644 --- a/client/src/components/form-items-formatted/read-only-form-item.component.jsx +++ b/client/src/components/form-items-formatted/read-only-form-item.component.jsx @@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); +const toFiniteNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => { - if (!value) return null; + if (value === null || value === undefined || value === "") return null; switch (type) { case "employee": { const emp = bodyshop.employees.find((e) => e.id === value); @@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => { case "text": return
{value}
; - case "currency": - return
{Dinero({ amount: Math.round(value * 100) }).toFormat()}
; + case "currency": { + const numericValue = toFiniteNumber(value); + + if (numericValue === null) { + return null; + } + + return
{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}
; + } default: return
{value}
; } diff --git a/client/src/components/form-list-move-arrows/form-list-move-arrows.component.jsx b/client/src/components/form-list-move-arrows/form-list-move-arrows.component.jsx index 75047277d..3c2e6a239 100644 --- a/client/src/components/form-list-move-arrows/form-list-move-arrows.component.jsx +++ b/client/src/components/form-list-move-arrows/form-list-move-arrows.component.jsx @@ -1,7 +1,7 @@ import { DownOutlined, UpOutlined } from "@ant-design/icons"; import { Space } from "antd"; -export default function FormListMoveArrows({ move, index, total }) { +export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) { const upDisabled = index === 0; const downDisabled = index === total - 1; @@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) { }; return ( - + diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx index 3bcc32f14..02a9366f6 100644 --- a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx +++ b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx @@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({ technician: selectTechnician }); +const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || ""; + export function PayrollLaborAllocationsTable({ jobId, joblines, @@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({ }); const notification = useNotification(); - useEffect(() => { - async function CalculateTotals() { + const loadTotals = async () => { + try { const { data } = await axios.post("/payroll/calculatelabor", { jobid: jobId }); setTotals(data); + } catch (error) { + setTotals([]); + notification.error({ + title: getRequestErrorMessage(error) + }); } + }; + useEffect(() => { if (!!joblines && !!timetickets && !!bodyshop) { - CalculateTotals(); + loadTotals(); } if (!jobId) setTotals([]); }, [joblines, timetickets, bodyshop, adjustments, jobId]); @@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({ + + + +
+ + +
+
+ + + 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( + "toggle", + (event) => { + const details = event.target; + + if (!(details instanceof HTMLDetailsElement) || !details.matches(".card")) { + return; + } + + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + state.openIds.add(id); + } else { + state.openIds.delete(id); + } + + hydrate(details, getMessage(id)); + }, + true + ); + + 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(""); + el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.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", From 98781a76e6854d6149390809aa3f56ee445bfffb Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 14:39:30 -0400 Subject: [PATCH 9/9] Localstack Email Viewer --- _reference/localEmailViewer/index.js | 47 +++++++++++++--------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 4106d2903..15b7736a9 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -468,32 +468,6 @@ function clientApp(config) { renderList(); }); - el.list.addEventListener( - "toggle", - (event) => { - const details = event.target; - - if (!(details instanceof HTMLDetailsElement) || !details.matches(".card")) { - return; - } - - const id = details.dataset.id; - - if (!id) { - return; - } - - if (details.open) { - state.openIds.add(id); - } else { - state.openIds.delete(id); - } - - hydrate(details, getMessage(id)); - }, - true - ); - el.list.addEventListener("click", async (event) => { const button = event.target.closest("button[data-action]"); @@ -749,9 +723,30 @@ function clientApp(config) { 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");