/* eslint-disable */
/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved.
Proprietary and confidential. Unauthorized copying, distribution, or use of
this file, via any medium, is strictly prohibited. Original work product. */
// JBREWS DMS — Trade-In Requests (sales features, lane 2). Route "trades".
//
// Salesperson : "Send a trade" (VIN look-up, miles, condition, payoff, customer, up to 8 photos made
// smaller in the browser) and "My trades" (status; once valued: the OFFER TO PRESENT and
// the manager's note). A salesperson never sees ACV, the market value, MSRP or a range —
// the server never sends them to a Sales login (/api/trades TradeView).
// Sales manager: the trade queue (photos, decoded vehicle, MarketCheck market value, ACV, offer to
// present, notes, "Not taken"). Sending the offer alerts the salesperson and writes the
// numbers onto the appraisal / lead / desked deal (server side).
//
// Server: /api/trades/* and GET /api/sales-alerts/me (feature flags). Without them (an older server) the
// screen shows #tre-unavailable. Photos are fetched with DMS.apiBlob and shown ONLY inside .
// Hand-offs (localStorage, read once): jb_trade_lead (CRM -> prefill), jb_trade_focus (open one trade).
// Every top-level name here starts with tre / Tre.
const TRE_COND_FLAGS = [
["accident", "Accident history"], ["mechanical", "Mechanical problems"], ["warning_lights", "Warning lights on"],
["tires", "Tires need replacing"], ["glass", "Cracked windshield"], ["interior", "Interior damage"],
["smoker", "Smoked in"], ["pets", "Pet odor/hair"], ["aftermarket", "Aftermarket parts"], ["title", "Title problems"],
];
const TRE_OVERALL = ["Excellent", "Good", "Fair", "Rough"];
const TRE_PHOTO_LABELS = ["Front", "Rear", "Driver side", "Passenger side", "Interior", "Odometer", "Damage", "Other"];
const TRE_MAX_PHOTOS = 8;
// ── small helpers ───────────────────────────────────────────────────────────
const treIsMgr = () => !!(window.DMS && ((DMS.isAdmin && DMS.isAdmin()) || (DMS.roleIs && DMS.roleIs("Sales Manager"))));
const treIsSales = () => !!(window.DMS && DMS.roleIs && DMS.roleIs("Sales"));
const treNormVin = (v) => String(v || "").toUpperCase().replace(/[\s-]/g, "");
const treIsVin = (v) => /^[A-HJ-NPR-Z0-9]{17}$/.test(treNormVin(v));
const treMoney = (n) => {
const v = Number(n);
if (n === null || n === undefined || n === "" || !isFinite(v)) return "—";
return "$" + v.toLocaleString("en-US", { minimumFractionDigits: Math.round(v) === v ? 0 : 2, maximumFractionDigits: 2 });
};
const treMiles = (n) => (n === null || n === undefined || n === "" ? "—" : Number(n).toLocaleString("en-US") + " mi");
// date-only "YYYY-MM-DD" -> "Mon Oct 12" built from its parts (never new Date("YYYY-MM-DD"))
const treFmtDay = (day) => {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(day || ""));
if (!m) return "";
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toLocaleDateString("en-US", { timeZone: "UTC", weekday: "short", month: "short", day: "numeric" }).replace(/,/g, "");
};
// a timestamp -> local "Mon Oct 12, 2:14 PM"
const treFmtWhen = (iso) => {
const d = new Date(iso || "");
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }).replace(/,/g, "") + ", " +
d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
};
const treFmtTime = (iso) => { const d = new Date(iso || ""); return isNaN(d.getTime()) ? "" : d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); };
const treAgo = (iso, ageMinutes) => {
let min = Number(ageMinutes);
if (!isFinite(min) || min <= 0) { const t = Date.parse(iso || ""); min = isNaN(t) ? 0 : Math.max(0, Math.round((Date.now() - t) / 60000)); }
if (min < 1) return "just now";
if (min < 60) return min + " min ago";
if (min < 48 * 60) return Math.round(min / 60) + " h ago";
return Math.round(min / 1440) + " days ago";
};
const treVehicle = (t) => [t && t.year, t && t.make, t && t.model].filter((x) => x !== null && x !== undefined && String(x).trim() !== "").join(" ").toUpperCase() || (t && t.vin) || "Trade";
const treErrText = (e, fallback) => (e && e.body && e.body.error) || fallback || (e && e.message) || "Something went wrong. Try again.";
const treFlagLabel = (k) => { const hit = TRE_COND_FLAGS.find((x) => x[0] === k); return hit ? hit[1] : k; };
const treUp = (s) => String(s == null ? "" : s).toUpperCase();
const treDigits = (s) => String(s == null ? "" : s).replace(/[^0-9]/g, "");
const treLsTake = (key) => { try { const v = localStorage.getItem(key); if (v != null) localStorage.removeItem(key); return v; } catch (e) { return null; } };
// ── feature detection: GET /api/sales-alerts/me (the UI's only source of feature flags) ──
let treMeCache = { state: "unknown", me: null };
const treLoadMe = async () => {
try {
const r = await apiFetch("/sales-alerts/me");
treMeCache = (r && r.ok) ? { state: "on", me: r } : { state: "off", me: null };
} catch (e) {
treMeCache = { state: "off", me: null };
}
return treMeCache;
};
const useTreMe = () => {
// a cached answer belongs to the person who was signed in when it was fetched
const fresh = () => {
const who = (window.DMS && DMS.userId) ? String(DMS.userId() || "") : "";
return (treMeCache.me && treMeCache.me.user && String(treMeCache.me.user.id || "") !== who) ? { state: "unknown", me: null } : treMeCache;
};
const [st, setSt] = React.useState(fresh);
React.useEffect(() => {
let alive = true;
const run = () => treLoadMe().then((c) => { if (alive) setSt({ state: c.state, me: c.me }); });
run();
const off = (window.DMS && DMS.onConn) ? DMS.onConn((s) => { if (s === "online") run(); }) : null;
return () => { alive = false; if (typeof off === "function") off(); };
}, []);
return st;
};
// ── photos: decoded in the browser and saved as JPEG (longest side 1600 px @0.8; > 350 KB -> 1280 @0.7;
// > 440 KB -> 1024 @0.6; still bigger -> smaller again), plus a 320 px thumbnail @0.7 ──
const treReadB64 = (blob) => new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result || "").replace(/^data:[^,]*,/, ""));
fr.onerror = () => rej(fr.error || new Error("read failed"));
fr.readAsDataURL(blob);
});
const treLoadPicture = async (file) => {
if (window.createImageBitmap) {
try { return await createImageBitmap(file, { imageOrientation: "from-image" }); } catch (e) { /* fall back to */ }
}
return await new Promise((res, rej) => {
const u = URL.createObjectURL(file);
const im = new Image();
im.onload = () => { setTimeout(() => URL.revokeObjectURL(u), 2000); res(im); };
im.onerror = () => { URL.revokeObjectURL(u); rej(new Error("That file is not a picture.")); };
im.src = u;
});
};
const treEncodeJpeg = (img, maxPx, quality) => new Promise((res, rej) => {
const w0 = img.width || img.naturalWidth || 1, h0 = img.height || img.naturalHeight || 1;
const k = Math.min(1, maxPx / Math.max(w0, h0));
const w = Math.max(1, Math.round(w0 * k)), h = Math.max(1, Math.round(h0 * k));
const c = document.createElement("canvas"); c.width = w; c.height = h;
const g = c.getContext("2d"); g.fillStyle = "#FFFFFF"; g.fillRect(0, 0, w, h); g.drawImage(img, 0, 0, w, h);
c.toBlob((b) => (b ? res({ blob: b, w, h }) : rej(new Error("The picture could not be prepared."))), "image/jpeg", quality);
});
const treShrinkPhoto = async (file) => {
const img = await treLoadPicture(file);
let out = await treEncodeJpeg(img, 1600, 0.8);
if (out.blob.size > 350000) out = await treEncodeJpeg(img, 1280, 0.7);
if (out.blob.size > 440000) out = await treEncodeJpeg(img, 1024, 0.6);
let px = 1024;
while (out.blob.size > 440000 && px > 400) { px = Math.round(px * 0.8); out = await treEncodeJpeg(img, px, 0.55); }
let th = await treEncodeJpeg(img, 320, 0.7);
if (th.blob.size > 58000) th = await treEncodeJpeg(img, 320, 0.5);
if (th.blob.size > 58000) th = await treEncodeJpeg(img, 220, 0.45);
try { if (img.close) img.close(); } catch (e) {}
return { data: await treReadB64(out.blob), size: out.blob.size, width: out.w, height: out.h,
thumb: await treReadB64(th.blob), thumbSize: th.blob.size, url: URL.createObjectURL(th.blob) };
};
// Photo bytes -> object URL (cached per path while the screen is open; revoked when it closes).
const treBlobCache = new Map();
const treBlobUrl = (path) => {
if (!treBlobCache.has(path)) {
const p = DMS.apiBlob(path).then((b) => URL.createObjectURL(b));
p.catch(() => treBlobCache.delete(path));
treBlobCache.set(path, p);
}
return treBlobCache.get(path);
};
const treBlobClear = () => {
treBlobCache.forEach((p) => { p.then((u) => { try { URL.revokeObjectURL(u); } catch (e) {} }).catch(() => {}); });
treBlobCache.clear();
};
const TreImg = ({ path, alt, style, onClick, className }) => {
const [src, setSrc] = React.useState(null);
const [err, setErr] = React.useState(false);
React.useEffect(() => {
let alive = true; setSrc(null); setErr(false);
treBlobUrl(path).then((u) => { if (alive) setSrc(u); }).catch(() => { if (alive) setErr(true); });
return () => { alive = false; };
}, [path]);
if (err) return no picture ;
if (!src) return ;
return ;
};
// In-page photo viewer (never a new window / tab / iframe).
const TrePhotoViewer = ({ tradeId, photos, index, onClose }) => {
const [i, setI] = React.useState(index || 0);
React.useEffect(() => {
const k = (e) => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === "ArrowRight") setI((x) => Math.min(photos.length - 1, x + 1));
else if (e.key === "ArrowLeft") setI((x) => Math.max(0, x - 1));
};
document.addEventListener("keydown", k, true);
return () => document.removeEventListener("keydown", k, true);
}, [photos.length]);
const p = photos[i];
if (!p) return null;
return (
e.stopPropagation()} style={{ display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 13 }}>
setI(i - 1)}>‹ Previous
Photo {i + 1} of {photos.length}{p.label ? " — " + p.label : ""}
= photos.length - 1} onClick={() => setI(i + 1)}>Next ›
Close ✕
e.stopPropagation()} style={{ maxWidth: "100%", maxHeight: "calc(100% - 60px)", display: "flex" }}>
);
};
// Thumbnails of one trade (+ add / remove photos where the server allows it).
const TrePhotoStrip = ({ trade, canAdd, canDelete, onChanged, galleryClass }) => {
const [viewAt, setViewAt] = React.useState(-1);
const [busy, setBusy] = React.useState("");
const [err, setErr] = React.useState("");
const inputRef = React.useRef(null);
const photos = (trade.photos || []).slice().sort((a, b) => a.seq - b.seq);
const room = TRE_MAX_PHOTOS - photos.length;
const add = async (files) => {
const list = Array.from(files || []).slice(0, Math.max(0, room));
if (!list.length) { setErr(`A trade can have ${TRE_MAX_PHOTOS} photos.`); return; }
setErr("");
for (let k = 0; k < list.length; k++) {
setBusy(`Uploading photo ${k + 1} of ${list.length}…`);
try {
const p = await treShrinkPhoto(list[k]);
try { URL.revokeObjectURL(p.url); } catch (e) {}
await apiFetch("/trades/" + encodeURIComponent(trade.id) + "/photos", { method: "POST", body: { label: "Other", mime: "image/jpeg", data: p.data, thumb: p.thumb, thumbMime: "image/jpeg" } });
} catch (e) {
setErr(treErrText(e, `Photo ${k + 1} could not be sent. Try again.`)); break;
}
}
setBusy("");
if (inputRef.current) inputRef.current.value = "";
onChanged && onChanged();
};
const del = async (p) => {
setErr(""); setBusy("Removing photo…");
try { await apiFetch("/trades/" + encodeURIComponent(trade.id) + "/photos/" + encodeURIComponent(p.id), { method: "DELETE" }); }
catch (e) { setErr(treErrText(e, "That photo could not be removed.")); }
setBusy(""); onChanged && onChanged();
};
return (
{photos.length === 0 &&
No photos. }
{photos.map((p, i) => (
setViewAt(i)} style={{ width: 120, height: 84, objectFit: "cover", borderRadius: 5, border: "1px solid var(--jb-border)", cursor: "zoom-in" }} />
{p.label || "Photo " + p.seq}
{canDelete && del(p)} style={{ fontSize: 10, padding: "0 6px" }}>Remove }
))}
{canAdd && room > 0 && (
+ Add photos ({room} left)
add(e.target.files)} />
)}
{busy &&
{busy}
}
{err &&
{err}
}
{viewAt >= 0 &&
setViewAt(-1)} />}
);
};
// ── shared styles ───────────────────────────────────────────────────────────
const treLbl = { fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--jb-text-muted)", display: "block", marginBottom: 3 };
const treInp = { width: "100%", boxSizing: "border-box", fontSize: 13, padding: "6px 8px", border: "1px solid #C8CCD2", borderRadius: 4, fontFamily: "var(--jb-font)", background: "#FFF", color: "var(--jb-text)" };
const treCard = { background: "#FFF", border: "1px solid var(--jb-border)", borderRadius: "var(--jb-r, 8px)", padding: "12px 14px" };
const treH = { fontSize: 13, fontWeight: 800, color: "var(--jb-navy)", marginBottom: 8, textTransform: "uppercase", letterSpacing: "0.03em" };
const treMsgStyle = (tone) => ({ fontSize: 12.5, padding: "6px 10px", borderRadius: 5, marginTop: 8,
background: tone === "err" ? "#FBE9E7" : tone === "ok" ? "#E6F4EA" : "#EEF4FB",
color: tone === "err" ? "#B23A2E" : tone === "ok" ? "#0E7A37" : "var(--jb-navy)" });
// Live refresh: the server pushes {type:'sales_trade'} over the WebSocket (DMSCore hands unknown message
// types to the page as a `k2b-ws` event) — plus a 60 s poll while the screen is open.
const useTreLive = (reload) => {
const ref = React.useRef(reload); ref.current = reload;
React.useEffect(() => {
const onWs = (e) => { const m = e && e.detail; if (m && m.type === "sales_trade") ref.current && ref.current(m); };
window.addEventListener("k2b-ws", onWs);
const t = setInterval(() => ref.current && ref.current(null), 60000);
const onFocus = () => ref.current && ref.current(null);
window.addEventListener("focus", onFocus);
return () => { window.removeEventListener("k2b-ws", onWs); window.removeEventListener("focus", onFocus); clearInterval(t); };
}, []);
};
/* ══════════════════════════════════════════════════════════════════════════
Salesperson view
══════════════════════════════════════════════════════════════════════════ */
const treBlankForm = () => ({ vin: "", year: "", make: "", model: "", trim: "", miles: "", color: "", overall: "", flags: {}, notes: "", payoff: "", lender: "" });
const TreSalesView = ({ me, go }) => {
const [f, setF] = React.useState(treBlankForm);
const set = (k, v) => setF((s) => Object.assign({}, s, { [k]: v }));
const [dec, setDec] = React.useState({ state: "none" }); // none | busy | ok | no
const [needConfirm, setNeedConfirm] = React.useState(false);
const [vinOk, setVinOk] = React.useState(false);
const [lead, setLead] = React.useState(null); // {leadId, name, custNum}
const [cust, setCust] = React.useState(null); // {custNum, name, phone}
const [custQ, setCustQ] = React.useState("");
const [photos, setPhotos] = React.useState([]); // [{key, label, data, thumb, size, width, height, url}]
const [prepping, setPrepping] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState(null); // {text, tone}
const [pending, setPending] = React.useState(null); // {tradeId, vehicle, photos:[{..., state, err}]}
const [trades, setTrades] = React.useState([]);
const [listErr, setListErr] = React.useState("");
const [loaded, setLoaded] = React.useState(false);
const [openPhotos, setOpenPhotos] = React.useState({});
const [confirmW, setConfirmW] = React.useState("");
const [focusId, setFocusId] = React.useState(null);
const fileRef = React.useRef(null);
const keySeq = React.useRef(1);
const load = async () => {
try { const r = await apiFetch("/trades?status=all&limit=100"); setTrades((r && r.trades) || []); setListErr(""); }
catch (e) { setListErr(treErrText(e, "Your trades could not be loaded right now.")); }
setLoaded(true);
};
React.useEffect(() => { load(); }, []);
useTreLive(() => load());
// hand-offs: CRM prefill (jb_trade_lead) and "open this trade" (jb_trade_focus)
React.useEffect(() => {
const raw = treLsTake("jb_trade_lead");
if (raw) {
let p = null; try { p = JSON.parse(raw); } catch (e) {}
if (p && typeof p === "object") {
if (p.leadId) setLead({ leadId: String(p.leadId), name: String(p.name || ""), custNum: String(p.custNum || "") });
setF((s) => Object.assign({}, s, { vin: treNormVin(p.vin), miles: treDigits(p.miles), year: treDigits(p.year).slice(0, 4), make: treUp(p.make), model: treUp(p.model),
trim: treUp(p.trim), color: String(p.color == null ? "" : p.color), // the VIN Decode screen hands these over too
payoff: +p.payoff > 0 ? String(+p.payoff) : "", lender: treUp(p.lender) }));
if (p.custNum) {
const c = (DMS.customers && DMS.customers()[p.custNum]) || null;
setCust({ custNum: String(p.custNum), name: String(p.name || (c && [c.lastName, c.firstName].filter(Boolean).join(", ")) || p.custNum), phone: (c && c.phone) || "" });
}
}
}
const fid = treLsTake("jb_trade_focus");
if (fid) setFocusId(fid);
}, []);
React.useEffect(() => {
if (!focusId || !trades.length) return;
const el = document.querySelector('.tre-row[data-trade-id="' + String(focusId).replace(/"/g, "") + '"]');
if (el && el.scrollIntoView) el.scrollIntoView({ block: "center" });
}, [focusId, trades.length]);
const vinNow = treNormVin(f.vin);
const decoded = dec.state === "ok" && dec.vin === vinNow;
const changeVin = (v) => {
const nv = treNormVin(v).slice(0, 17);
set("vin", nv);
if (nv !== dec.vin) { setDec({ state: "none" }); setNeedConfirm(false); setVinOk(false); }
};
const lookUp = async () => {
setMsg(null);
if (!treIsVin(vinNow)) { setMsg({ text: "A VIN has 17 letters and numbers (the letters I, O and Q are never used).", tone: "err" }); return; }
setDec({ state: "busy", vin: vinNow });
try {
const r = await apiFetch("/trades/decode", { method: "POST", body: { vin: vinNow } });
const v = (r && r.vehicle) || {};
if (r && r.decoded) {
setF((s) => Object.assign({}, s, { year: v.year ? String(v.year) : "", make: treUp(v.make), model: treUp(v.model), trim: treUp(v.trim), color: s.color || String(v.exteriorColor || "") }));
const text = [[v.year, treUp(v.make), treUp(v.model), treUp(v.trim)].filter(Boolean).join(" "), v.engine, v.drivetrain].filter(Boolean).join(", ");
setDec({ state: "ok", vin: vinNow, text });
setNeedConfirm(false);
} else {
// a partial decode (the server read the year / make from the VIN itself): fill only what is still blank, the rest is typed
if (r && r.partial && (v.year || v.make)) setF((s) => Object.assign({}, s, { year: s.year || (v.year ? String(v.year) : ""), make: s.make || treUp(v.make) }));
setDec({ state: "no", vin: vinNow, text: (r && r.note) || "We could not find this VIN. Type the year, make and model." });
}
} catch (e) {
const code = e && e.body && e.body.code;
if (code === "bad_check_digit") { setNeedConfirm(true); setDec({ state: "no", vin: vinNow, text: treErrText(e) }); }
else setDec({ state: "no", vin: vinNow, text: treErrText(e, "VIN look-up is not available right now. Type the year, make and model.") });
}
};
// customers for the picker (the RO editor's search: name, phone, email, customer #)
const custRows = React.useMemo(() => (custQ.trim().length >= 2 && DMS.customerSearch ? DMS.customerSearch(custQ).slice(0, 8) : []), [custQ]);
const addFiles = async (files) => {
const list = Array.from(files || []);
if (fileRef.current) fileRef.current.value = "";
if (!list.length) return;
const room = TRE_MAX_PHOTOS - photos.length;
if (room <= 0) { setMsg({ text: `A trade can have ${TRE_MAX_PHOTOS} photos.`, tone: "err" }); return; }
const take = list.slice(0, room);
const out = [];
for (let k = 0; k < take.length; k++) {
setPrepping(`Preparing photo ${k + 1} of ${take.length}…`);
try {
const p = await treShrinkPhoto(take[k]);
const n = photos.length + out.length;
out.push(Object.assign(p, { key: "p" + (keySeq.current++), label: TRE_PHOTO_LABELS[Math.min(n, TRE_PHOTO_LABELS.length - 1)] }));
} catch (e) { setMsg({ text: `Photo ${k + 1} is not a picture this screen can read. Take it again.`, tone: "err" }); }
}
setPrepping("");
if (list.length > room) setMsg({ text: `Only ${TRE_MAX_PHOTOS} photos fit on a trade — the first ${room} were added.`, tone: "warn" });
setPhotos((s) => s.concat(out));
};
const removePhoto = (key) => setPhotos((s) => s.filter((p) => { if (p.key === key) { try { URL.revokeObjectURL(p.url); } catch (e) {} } return p.key !== key; }));
const uploadAll = async (tradeId, list) => {
const res = list.map((p) => Object.assign({}, p, { state: "waiting", err: "" }));
for (let k = 0; k < res.length; k++) {
setMsg({ text: `Trade sent. Uploading photo ${k + 1} of ${res.length}…`, tone: "info" });
try {
await apiFetch("/trades/" + encodeURIComponent(tradeId) + "/photos", { method: "POST",
body: { label: res[k].label || "Other", mime: "image/jpeg", data: res[k].data, thumb: res[k].thumb, thumbMime: "image/jpeg" } });
res[k].state = "sent";
} catch (e) {
const st = e && e.status;
res[k].state = "failed";
res[k].err = (st === 409 || st === 413 || st === 415 || st === 429) ? treErrText(e) : `Photo ${k + 1} could not be sent. Try again.`;
}
}
return res;
};
const submit = async () => {
setMsg(null);
if (!treIsVin(vinNow)) { setMsg({ text: "Type the 17-character VIN.", tone: "err" }); return; }
const miles = treDigits(f.miles);
if (!miles) { setMsg({ text: "Type the miles on the odometer.", tone: "err" }); return; }
if (!f.overall) { setMsg({ text: "Pick the overall condition: Excellent, Good, Fair or Rough.", tone: "err" }); return; }
if (needConfirm && !vinOk) { setMsg({ text: "That VIN does not pass the VIN check. Check it against the windshield plate; if it is right, tick 'VIN is correct'.", tone: "err" }); return; }
const body = {
vin: vinNow, miles: parseInt(miles, 10), color: f.color.trim() || undefined,
year: f.year ? parseInt(f.year, 10) : undefined, make: f.make.trim() || undefined, model: f.model.trim() || undefined, trim: f.trim.trim() || undefined,
condition: { overall: f.overall, flags: TRE_COND_FLAGS.map((x) => x[0]).filter((k) => f.flags[k]), notes: f.notes.trim() },
payoff: treDigits(f.payoff.split(".")[0]) ? Number(String(f.payoff).replace(/[^0-9.]/g, "")) : undefined,
payoffLender: f.lender.trim() || undefined,
custNum: cust ? cust.custNum : undefined, custName: cust ? cust.name : (lead ? lead.name : undefined),
leadId: lead ? lead.leadId : undefined,
};
if (needConfirm && vinOk) body.vinConfirmed = true;
setBusy(true);
let trade = null;
try {
const r = await apiFetch("/trades", { method: "POST", body });
trade = r && r.trade;
} catch (e) {
const code = e && e.body && e.body.code;
if (code === "bad_check_digit") setNeedConfirm(true);
setMsg({ text: treErrText(e, "The trade could not be sent. Try again."), tone: "err" });
setBusy(false);
return;
}
let sentPhotos = [];
if (trade && photos.length) sentPhotos = await uploadAll(trade.id, photos);
const failed = sentPhotos.filter((p) => p.state === "failed");
sentPhotos.filter((p) => p.state === "sent").forEach((p) => { try { URL.revokeObjectURL(p.url); } catch (e) {} });
setPending(failed.length ? { tradeId: trade.id, vehicle: treVehicle(trade), photos: failed } : null);
setMsg({ text: failed.length
? `Trade sent to a manager, but ${failed.length} photo${failed.length === 1 ? "" : "s"} did not go through — see below.`
: `Trade sent to a manager${sentPhotos.length ? " with " + sentPhotos.length + " photo" + (sentPhotos.length === 1 ? "" : "s") : ""}. You will get an alert when the offer is ready.`,
tone: failed.length ? "err" : "ok" });
setF(treBlankForm()); setDec({ state: "none" }); setNeedConfirm(false); setVinOk(false); setPhotos([]); setLead(null); setCust(null); setCustQ("");
setBusy(false);
setFocusId(trade ? trade.id : null);
load();
};
const retryPhoto = async (key) => {
if (!pending) return;
const p = pending.photos.find((x) => x.key === key); if (!p) return;
const res = await uploadAll(pending.tradeId, [p]);
const left = pending.photos.map((x) => (x.key === key ? res[0] : x)).filter((x) => x.state !== "sent");
if (res[0].state === "sent") { try { URL.revokeObjectURL(p.url); } catch (e) {} }
setPending(left.length ? Object.assign({}, pending, { photos: left }) : null);
setMsg(left.length ? { text: `${left.length} photo${left.length === 1 ? "" : "s"} still not sent.`, tone: "err" } : { text: "All photos are sent.", tone: "ok" });
load();
};
const withdraw = async (t) => {
setConfirmW("");
try { await apiFetch("/trades/" + encodeURIComponent(t.id) + "/withdraw", { method: "POST", body: {} }); setMsg({ text: `${treVehicle(t)} was withdrawn.`, tone: "ok" }); }
catch (e) { setMsg({ text: treErrText(e, "The trade could not be withdrawn."), tone: "err" }); }
load();
};
const roVeh = decoded; // decoded vehicle fields are read-only (they came from the VIN)
return (
{/* ── send a trade ── */}
{/* ── my trades ── */}
{listErr &&
{listErr}
}
{loaded && !listErr && trades.length === 0 &&
No trades sent yet.
}
{trades.map((t) => {
const open = !!openPhotos[t.id];
const canW = (t.status === "submitted" || t.status === "valued") && !t.dealId;
return (
{treVehicle(t)}
{treMiles(t.miles)}
{t.custName &&
· {t.custName} }
sent {treFmtWhen(t.createdAt)}
{t.status === "submitted" &&
Waiting for a manager }
{t.status === "valued" && (
Offer to present: {treMoney(t.offer)}
{t.offerNote &&
“{t.offerNote}”
}
from {t.valuedBy ? t.valuedBy.name : "a manager"}{t.valuedAt ? ", " + treFmtWhen(t.valuedAt) : ""}
)}
{t.status === "declined" && (
Not taken
{t.offerNote &&
“{t.offerNote}”
}
{t.valuedBy ? t.valuedBy.name : ""}{t.valuedAt ? ", " + treFmtWhen(t.valuedAt) : ""}
)}
{t.status === "withdrawn" &&
Withdrawn }
{t.dealId &&
On deal {t.dealId}
}
setOpenPhotos(Object.assign({}, openPhotos, { [t.id]: !open }))}>
{open ? "Hide photos" : `Photos (${(t.photos || []).length})`}
{canW && confirmW !== t.id && setConfirmW(t.id)}>Withdraw }
{canW && confirmW === t.id && <>
withdraw(t)}>Yes, withdraw it
setConfirmW("")}>Keep it
>}
{open && (
)}
);
})}
);
};
/* ══════════════════════════════════════════════════════════════════════════
Manager view: the trade queue
══════════════════════════════════════════════════════════════════════════ */
const TRE_Q_TABS = [["submitted", "Waiting"], ["valued", "Valued"], ["declined", "Not taken"], ["all", "All"]];
const treEventText = (e) => {
const d = e.detail || {};
switch (e.action) {
case "submit": return "Sent by the salesperson" + (d.miles != null ? " (" + treMiles(d.miles) + ")" : "");
case "decode": return "VIN look-up: " + (d.status === "ok" ? "found" : String(d.status || "").replace(/_/g, " "));
case "value_lookup": return "Market value looked up: " + treMoney(d.predictedPrice) + (d.source ? " (" + d.source + ")" : "");
case "offer": return "Offer " + treMoney(d.offer) + (d.acv != null ? " · ACV " + treMoney(d.acv) : "");
case "offer_change": return "Offer changed to " + treMoney(d.offer) + (d.acv != null ? " · ACV " + treMoney(d.acv) : "");
case "decline": return "Not taken" + (d.note ? ": " + d.note : "");
case "withdraw": return "Withdrawn";
case "photo_add": return "Photo added";
case "photo_delete": return "Photo removed";
case "deal_linked": return "Attached to a deal";
default: return e.action;
}
};
const TreManagerView = ({ me, go }) => {
const [tab, setTab] = React.useState("submitted");
const [list, setList] = React.useState([]);
const [listErr, setListErr] = React.useState("");
const [loaded, setLoaded] = React.useState(false);
const [q, setQ] = React.useState("");
const [selId, setSelId] = React.useState(null);
const [detail, setDetail] = React.useState(null);
const [detErr, setDetErr] = React.useState("");
const [market, setMarket] = React.useState(null);
const [mktBusy, setMktBusy] = React.useState(false);
const [mktErr, setMktErr] = React.useState("");
const [form, setForm] = React.useState({ acv: "", offer: "", offerNote: "", internalNote: "" });
const [editing, setEditing] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const selRef = React.useRef(null); selRef.current = selId;
const loadList = async () => {
try { const r = await apiFetch("/trades?status=all&limit=200"); setList((r && r.trades) || []); setListErr(""); }
catch (e) { setListErr(treErrText(e, "The trade queue could not be loaded right now.")); }
setLoaded(true);
};
const loadDetail = async (id, keepForm) => {
if (!id) { setDetail(null); return; }
try {
const r = await apiFetch("/trades/" + encodeURIComponent(id));
const t = r && r.trade;
if (selRef.current !== id) return;
setDetail(t); setDetErr("");
setMarket(t && t.market ? t.market : null);
if (!keepForm) {
setForm({ acv: t && t.acv != null ? String(t.acv) : "", offer: t && t.offer != null ? String(t.offer) : "", offerNote: (t && t.offerNote) || "", internalNote: (t && t.internalNote) || "" });
setEditing(!(t && (t.status === "valued" || t.status === "declined" || t.status === "withdrawn")));
}
} catch (e) { setDetErr(treErrText(e, "That trade could not be loaded.")); }
};
React.useEffect(() => {
loadList();
const fid = treLsTake("jb_trade_focus");
if (fid) { setTab("all"); setSelId(fid); }
}, []);
React.useEffect(() => { setMsg(null); setMktErr(""); setDetail(null); setMarket(null); loadDetail(selId, false); }, [selId]);
useTreLive((m) => { loadList(); if (selRef.current && (!m || m.tradeId === selRef.current)) loadDetail(selRef.current, true); });
const counts = {}; TRE_Q_TABS.forEach((x) => { counts[x[0]] = x[0] === "all" ? list.length : list.filter((t) => t.status === x[0]).length; });
const qq = q.trim().toLowerCase();
const shown = list.filter((t) => (tab === "all" || t.status === tab) &&
(!qq || [t.vin, treVehicle(t), t.custName, t.requestedBy && t.requestedBy.name].join(" ").toLowerCase().includes(qq) || (window.DMS && window.DMS.custQueryHit ? window.DMS.custQueryHit(qq, t.custNum) : false))); // RC31 (fx31, F6)
const lookUpValue = async () => {
if (!detail) return;
setMktBusy(true); setMktErr("");
try { const r = await apiFetch("/trades/" + encodeURIComponent(detail.id) + "/value-lookup", { method: "POST", body: { miles: detail.miles } }); setMarket(r && r.market); }
catch (e) { setMktErr(treErrText(e, "MarketCheck could not be reached. Try again in a minute.")); }
setMktBusy(false);
};
const num = (s) => { const v = String(s || "").replace(/[^0-9.]/g, ""); return v === "" ? null : Number(v); };
const sendOffer = async () => {
if (!detail) return;
const offer = num(form.offer);
if (!offer || offer < 1 || offer > 200000) { setMsg({ text: "Type the offer to present (between $1 and $200,000).", tone: "err" }); return; }
const acv = num(form.acv);
setBusy(true); setMsg(null);
try {
const r = await apiFetch("/trades/" + encodeURIComponent(detail.id) + "/offer", { method: "POST",
body: { offer, acv: acv === null ? undefined : acv, offerNote: form.offerNote.trim(), internalNote: form.internalNote.trim() } });
const t = r && r.trade;
if (t) { setDetail(t); setEditing(false); }
setMsg({ text: `Sent to ${(t && t.requestedBy && t.requestedBy.name) || "the salesperson"} at ${treFmtTime(t && t.valuedAt)}.`, tone: "ok" });
} catch (e) { setMsg({ text: treErrText(e, "The offer could not be sent."), tone: "err" }); }
setBusy(false);
loadList();
};
const decline = async () => {
if (!detail) return;
if (!form.offerNote.trim()) { setMsg({ text: "Write a note to the salesperson saying why the trade is not taken.", tone: "err" }); return; }
setBusy(true); setMsg(null);
try {
const r = await apiFetch("/trades/" + encodeURIComponent(detail.id) + "/decline", { method: "POST", body: { offerNote: form.offerNote.trim() } });
if (r && r.trade) { setDetail(r.trade); setEditing(false); }
setMsg({ text: `${(r && r.trade && r.trade.requestedBy && r.trade.requestedBy.name) || "The salesperson"} was told the trade is not taken.`, tone: "ok" });
} catch (e) { setMsg({ text: treErrText(e, "That could not be saved."), tone: "err" }); }
setBusy(false);
loadList();
};
const t = detail;
const dec = (t && t.decode) || null;
const closed = t && t.status === "withdrawn";
const lockInputs = !editing || closed;
return (
{/* queue */}
{TRE_Q_TABS.map((x) => (
setTab(x[0])} data-count={counts[x[0]]}
style={{ fontSize: 11.5, fontWeight: 700, padding: "3px 10px", borderRadius: 999, cursor: "pointer",
border: "1px solid " + (tab === x[0] ? "var(--jb-navy)" : "#D5DAE0"), background: tab === x[0] ? "var(--jb-navy)" : "#fff", color: tab === x[0] ? "#fff" : "#556" }}>
{x[1]} ({counts[x[0]]})
))}
⟳
setQ(e.target.value)} placeholder="🔍 VIN, vehicle, customer, salesperson" style={{ ...treInp, fontSize: 12.5 }} />
{listErr &&
}
{loaded && !listErr && shown.length === 0 && (
{tab === "submitted" ? "No trades waiting. New ones show up here (and on your bell) as soon as a salesperson sends them." : "Nothing here."}
)}
{shown.map((x) => (
setSelId(x.id)}
style={{ padding: "9px 12px", borderBottom: "1px solid var(--jb-border)", cursor: "pointer",
background: selId === x.id ? "var(--jb-blue-tint, #E5EFFA)" : "transparent", borderLeft: "3px solid " + (selId === x.id ? "var(--jb-blue)" : "transparent") }}>
{treVehicle(x)}
{treMiles(x.miles)}
{x.status === "submitted" ? "Waiting" : x.status === "valued" ? treMoney(x.offer) : x.status === "declined" ? "Not taken" : "Withdrawn"}
from {x.requestedBy ? x.requestedBy.name : "?"}{x.custName ? " · " + x.custName : ""} · {treFmtWhen(x.createdAt)} · 📷 {(x.photos || []).length}
))}
{/* detail */}
{!selId &&
Pick a trade on the left to see its photos, look up the market value and send the offer.
}
{selId && detErr &&
{detErr}
}
{t && (
{treVehicle(t)}{t.trim ? " " + t.trim : ""}
{t.vin}
{t.id}
{t.vinConfirmed &&
This VIN fails the VIN check digit; the salesperson confirmed it against the car, so it was not looked up.
}
Vehicle
Miles: {treMiles(t.miles)} {t.color ? " · " + t.color : ""}
{dec ? <>
{[dec.body, dec.engine, dec.drivetrain, dec.transmission, dec.fuel].filter(Boolean).join(" · ")}
MSRP when new: {treMoney(dec.msrp)}
{Array.isArray(dec.options) && dec.options.length > 0 &&
Options: {dec.options.slice(0, 8).map((o) => o.name).join(", ")}{dec.options.length > 8 ? "…" : ""}
}
> :
{t.decodeNote || "VIN not looked up."}
}
People & payoff
Salesperson: {t.requestedBy ? t.requestedBy.name : "?"}
Customer: {t.custName || "—"} {t.custNum ? ({t.custNum}) : null}
{t.leadId &&
Lead: {t.leadId}
}
Payoff: {treMoney(t.payoff)} {t.payoffLender ? " · " + t.payoffLender : ""}
Sent {treFmtWhen(t.createdAt)}
Condition
Overall: {(t.condition && t.condition.overall) || "—"}
{t.condition && (t.condition.flags || []).length > 0
?
{t.condition.flags.map((k) => {treFlagLabel(k)} )}
:
No problems checked.
}
{t.condition && t.condition.notes &&
“{t.condition.notes}”
}
Photos ({(t.photos || []).length})
loadDetail(t.id, true)} galleryClass="tre-gallery" />
Market value
{mktBusy ? "Looking up…" : market ? "⟳ Look up again" : "Get market value"}
{market ? (
MarketCheck price: {treMoney(market.predictedPrice)}
{(market.low != null || market.high != null) &&
Typical range: {treMoney(market.low)} – {treMoney(market.high)}
}
{market.comparablesCount != null ? market.comparablesCount + " comparable cars" : "No comparables count"}{market.miles != null ? " · priced at " + treMiles(market.miles) : ""}
as of {treAgo(market.fetchedAt, market.ageMinutes)}
) :
Not looked up yet. Each look-up is a paid MarketCheck call (cached for a day).
}
{mktErr &&
{mktErr}
}
Offer
{t.status === "valued" && !editing && (
Sent to {t.requestedBy ? t.requestedBy.name : "the salesperson"} at {treFmtTime(t.valuedAt)}{t.valuedBy ? " by " + t.valuedBy.name : ""}: offer to present {treMoney(t.offer)} {t.acv != null ? <> · ACV {treMoney(t.acv)}> : null}.
)}
{t.status === "declined" && !editing &&
Not taken{t.valuedBy ? " (" + t.valuedBy.name + ")" : ""}: “{t.offerNote}”
}
{closed &&
The salesperson withdrew this trade.
}
Note to the salesperson
History
{(t.events || []).map((e, i) => (
{treFmtWhen(e.at)}
{e.byName}
{treEventText(e)}
))}
{!(t.events || []).length &&
No history. }
)}
);
};
/* ══════════════════════════════════════════════════════════════════════════
The screen
══════════════════════════════════════════════════════════════════════════ */
const TradeEval = ({ onClose, go }) => {
const meSt = useTreMe();
React.useEffect(() => () => treBlobClear(), []);
const mgr = treIsMgr(), sales = treIsSales();
const me = meSt.me || {};
let view = null;
if (meSt.state === "on") view = (mgr && me.canValueTrades) ? "manager" : ((mgr || sales) && me.canSubmitTrades) ? "sales" : "denied";
else if (!mgr && !sales) view = "denied";
let body;
if (view === "denied") body = (
Trade-In Requests are for salespeople and sales managers.
);
else if (meSt.state === "unknown") body = Loading…
;
else if (meSt.state === "off") body = (
This needs the latest server update. Ask your administrator.
);
else if (view === "manager") body = ;
else body = ;
return (
{body}
ESC-Close
go && go("crm")}>CRM — Leads
{mgr &&
go && go("appraisal")}>Trade Appraisal }
{mgr &&
go && go("desking")}>Desking }
{view === "sales" ? "You see the offer to present once a manager sends it." : view === "manager" ? "The salesperson sees only the offer to present and your note." : ""}
);
};
// Screen rules (app.jsx navAllowed asks these first): Trade-In Requests for Sales + managers,
// the Trade Appraisal desk (values) for managers only.
if (typeof window !== "undefined") {
window.jbNavRule = window.jbNavRule || {};
window.jbNavRule.trades = () => treIsMgr() || treIsSales();
window.jbNavRule.appraisal = () => treIsMgr();
}
Object.assign(window, { TradeEval, treIsMgr, treShrinkPhoto, TrePhotoStrip, TrePhotoViewer });