This commit is contained in:
2026-07-15 17:38:48 -05:00
parent 201f5c1b95
commit 2925f8aa35
14 changed files with 2099 additions and 159 deletions

View File

@@ -16,27 +16,37 @@ const fieldDefs = [
["Name / Company", "name_company"],
["Prospective Buyer", "prospective_buyer"],
["Company", "company"],
null,
["Phone", "phone"],
["Cell", "cell"],
["Email", "email"],
null,
["Address", "address"],
["State", "state"],
["How did you hear", "how_did_you_hear"],
["Interested in updates", "interested_in_updates"],
["Background experience", "background_experience"],
["Types of businesses", "types_of_business_raw"],
["Businesses from Notes", "notes_business_raw"],
["Types of Businesses", "types_of_business_raw"],
["Background Experience", "background_experience"],
null,
["How Did You Hear", "how_did_you_hear"],
["Interested in Updates", "interested_in_updates"],
["Down Payment", { key: "down_payment_raw", fallback: "down_payment" }],
["Total Purchase Price", "total_purchase_price"],
["Date of Introduction", "date_of_introduction"],
["Down payment", "down_payment_raw"],
["Total purchase price", "total_purchase_price"],
null,
["Notes Page", "_notes_page"],
["Buyer Info Page", "_info_page"],
["CA Page", "_ca_page"],
];
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[char]);
const esc = (value) =>
String(value ?? "").replace(/[&<>"']/g, (char) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
function buildGroups(docs) {
const map = new Map();
@@ -63,12 +73,15 @@ function buildGroups(docs) {
function visibleGroups() {
const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
return groups.filter((group) => terms.every((term) => group.text.toLowerCase().includes(term)));
return groups.filter((group) =>
terms.every((term) => group.text.toLowerCase().includes(term))
);
}
function updateStatus(shownCount = visibleGroups().length) {
const source = state.dataSource === "sample" ? "sample data" : "JSON file";
status.textContent = `${shownCount} people / ${state.documents.length} documents · ${source}`;
status.textContent =
`${shownCount} people / ${state.documents.length} documents \u00b7 ${source}`;
status.className = "";
}
@@ -81,11 +94,17 @@ function renderList() {
const shown = visibleGroups();
people.innerHTML = shown.map((group) => `
<div class="person">
<div class="person-title">${esc(group.displayName)} <span class="muted">(${group.docs.length})</span></div>
${group.docs.map((doc) => `
<button class="doc ${doc.index === selectedIndex ? "active" : ""}" data-index="${doc.index}">
<div class="person-title">${
esc(group.displayName)
} <span class="muted">(${group.docs.length})</span></div>
${
group.docs.map((doc) => `
<button class="doc ${
doc.index === selectedIndex ? "active" : ""
}" data-index="${doc.index}">
${esc(doc.file_name)}
</button>`).join("")}
</button>`).join("")
}
</div>
`).join("");
updateStatus(shown.length);
@@ -99,13 +118,17 @@ async function loadPdf(index) {
pdf.removeAttribute("src");
viewer.classList.remove("loaded");
pdfMessage.hidden = false;
pdfMessage.textContent = "Loading PDF";
pdfMessage.textContent = "Loading PDF\u2026";
try {
const response = await fetch(`/api/pdf?index=${index}`, { cache: "no-store" });
const response = await fetch(`/api/pdf?index=${index}`, {
cache: "no-store",
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `PDF request failed with HTTP ${response.status}.`);
throw new Error(
body.error || `PDF request failed with HTTP ${response.status}.`,
);
}
const blob = await response.blob();
currentPdfUrl = URL.createObjectURL(blob);
@@ -123,13 +146,33 @@ function select(index) {
selectedIndex = index;
const doc = state.documents[index];
if (!doc) return;
let rowIdx = 0;
fields.innerHTML = `
<h2>${esc(doc.name_from_filename)}</h2>
<p class="muted">${esc(doc.file_name)} · ${esc(doc._doc_type || "unknown")} · ${esc(doc._pages_total ?? "?")} pages</p>
${doc._vision_error ? `<p class="error">Vision error: ${esc(doc._vision_error)}</p>` : ""}
${fieldDefs.map(([label, key]) => `
<div class="field"><b>${label}</b><div>${esc(doc[key] || "—")}</div></div>
`).join("")}
<p class="muted">${esc(doc.file_name)} \u00b7 ${
esc(doc._doc_type || "unknown")
} \u00b7 ${esc(doc._pages_total ?? "?")} pages</p>
${
doc._vision_error
? `<p class="error">Vision error: ${esc(doc._vision_error)}</p>`
: ""
}
${
fieldDefs.map((def) => {
if (!def) return '<hr class="field-sep">';
const [label, keyOrObj] = def;
let value;
if (typeof keyOrObj === "object") {
value = esc(doc[keyOrObj.key] || doc[keyOrObj.fallback] || "\u2014");
} else {
value = esc(doc[keyOrObj] || "\u2014");
}
const bgClass = rowIdx % 2 === 0 ? "row-even" : "row-odd";
rowIdx++;
return `<div class="field ${bgClass}"><b>${label}</b><div>${value}</div></div>`;
}).join("")
}
`;
void loadPdf(index);
renderList();
@@ -147,19 +190,29 @@ document.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
select(Math.max(0, Math.min(state.documents.length - 1, selectedIndex < 0 ? 0 : selectedIndex + delta)));
select(
Math.max(
0,
Math.min(
state.documents.length - 1,
selectedIndex < 0 ? 0 : selectedIndex + delta,
),
),
);
}
});
const dialog = document.querySelector("#settingsDialog");
const jsonPath = document.querySelector("#jsonPath");
const pdfBase = document.querySelector("#pdfBase");
const useAnonData = document.querySelector("#useAnonData");
const settingsError = document.querySelector("#settingsError");
const saveSettings = document.querySelector("#saveSettings");
document.querySelector("#settings").onclick = () => {
jsonPath.value = state.config.jsonPath;
pdfBase.value = state.config.pdfBaseDirectory;
jsonPath.value = state.config.jsonPath || "";
pdfBase.value = state.config.pdfBaseDirectory || "";
useAnonData.checked = !!state.config.useAnonymousData;
settingsError.hidden = true;
settingsError.textContent = "";
dialog.showModal();
@@ -177,6 +230,7 @@ saveSettings.onclick = async (event) => {
body: JSON.stringify({
jsonPath: jsonPath.value,
pdfBaseDirectory: pdfBase.value,
useAnonymousData: useAnonData.checked,
}),
});
const body = await response.json();
@@ -184,17 +238,46 @@ saveSettings.onclick = async (event) => {
dialog.close();
await load();
} catch (error) {
settingsError.textContent = error instanceof Error ? error.message : String(error);
settingsError.textContent = error instanceof Error
? error.message
: String(error);
settingsError.hidden = false;
} finally {
saveSettings.disabled = false;
}
};
let resizeTimer;
globalThis.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fetch("/api/window-size", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
width: globalThis.innerWidth,
height: globalThis.innerHeight,
}),
});
}, 300);
});
globalThis.addEventListener("beforeunload", () => {
navigator.sendBeacon(
"/api/window-size",
JSON.stringify({
width: globalThis.innerWidth,
height: globalThis.innerHeight,
}),
);
});
async function load() {
try {
const response = await fetch("/api/state", { cache: "no-store" });
if (!response.ok) throw new Error(`State request failed with HTTP ${response.status}.`);
if (!response.ok) {
throw new Error(`State request failed with HTTP ${response.status}.`);
}
state = await response.json();
groups = buildGroups(state.documents);
showError(state.loadError || "");

View File

@@ -1,44 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BizMatch QC</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<strong>BizMatch QC</strong>
<input id="search" placeholder="Search name, business, or address">
<button id="settings">Settings</button>
<span id="status"></span>
</header>
<div id="errorBanner" class="error-banner" hidden></div>
<main>
<aside><div id="people"></div></aside>
<section class="details"><div id="fields"></div></section>
<section class="viewer">
<iframe id="pdf" title="PDF document"></iframe>
<div id="pdfMessage">Select a document</div>
</section>
</main>
<dialog id="settingsDialog">
<form method="dialog">
<h2>Settings</h2>
<label>buyers_vision.json
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BizMatch QC</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<strong>BizMatch QC</strong>
<input id="search" placeholder="Search name, business, or address">
<button id="settings">Settings</button>
<span id="status"></span>
</header>
<div id="errorBanner" class="error-banner" hidden></div>
<main>
<aside>
<div id="people"></div>
</aside>
<section class="details">
<div id="fields"></div>
</section>
<section class="viewer">
<iframe id="pdf" title="PDF document"></iframe>
<div id="pdfMessage">Select a document</div>
</section>
</main>
<dialog id="settingsDialog">
<form method="dialog">
<h2>Settings</h2>
<label class="checkbox-label">
<input type="checkbox" id="useAnonData">
Use anonymized sample data
</label>
<label>buyers_vision.json
<input id="jsonPath" autocomplete="off">
</label>
<label>PDF base directory
<label>PDF base directory
<input id="pdfBase" autocomplete="off">
</label>
<div id="settingsError" class="dialog-error" hidden></div>
<div class="actions">
<button value="cancel">Cancel</button>
<button id="saveSettings" value="default">Save</button>
</div>
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
<div id="settingsError" class="dialog-error" hidden></div>
<div class="actions">
<button value="cancel">Cancel</button>
<button id="saveSettings" value="default">Save</button>
</div>
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -1,5 +1,187 @@
*{box-sizing:border-box}body{margin:0;font:14px system-ui,sans-serif;color:#202124}header{height:52px;display:flex;align-items:center;gap:14px;padding:8px 14px;border-bottom:1px solid #ddd}header strong{font-size:18px}header input{flex:1;max-width:620px;padding:8px}header span{margin-left:auto;color:#666}main{height:calc(100vh - 52px);display:grid;grid-template-columns:330px 430px minmax(500px,1fr)}aside,.details{overflow:auto;border-right:1px solid #ddd}.person{border-bottom:1px solid #ddd}.person-title{font-weight:650;padding:10px 12px;background:#f6f7f8}.doc{display:block;width:100%;border:0;border-top:1px solid #eee;background:white;text-align:left;padding:8px 14px;cursor:pointer}.doc:hover,.doc.active{background:#e9f1ff}.details{padding:14px}.field{margin-bottom:13px}.field b{display:block;font-size:12px;color:#666;margin-bottom:3px;text-transform:uppercase}.field div{white-space:pre-wrap}.viewer{position:relative;background:#555}.viewer iframe{width:100%;height:100%;border:0;background:white}.viewer #pdfMessage{position:absolute;inset:0;display:grid;place-items:center;color:white;pointer-events:none}.viewer.loaded #pdfMessage{display:none}dialog{width:min(720px,90vw)}dialog label{display:block;margin:12px 0;font-weight:600}dialog input{display:block;width:100%;padding:8px;margin-top:5px}.actions{display:flex;justify-content:flex-end;gap:8px}.hint{color:#666}.error{color:#a40000}.muted{color:#777}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px system-ui,sans-serif;
color: #202124;
}
header {
height: 52px;
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
border-bottom: 1px solid #ddd;
}
header strong {
font-size: 18px;
}
header input {
flex: 1;
max-width: 620px;
padding: 8px;
}
header span {
margin-left: auto;
color: #666;
}
main {
height: calc(100vh - 52px);
display: grid;
grid-template-columns: 330px 430px minmax(500px,1fr);
}
aside,
.details {
overflow: auto;
border-right: 1px solid #ddd;
}
.person {
border-bottom: 1px solid #ddd;
}
.person-title {
font-weight: 650;
padding: 10px 12px;
background: #f6f7f8;
}
.doc {
display: block;
width: 100%;
border: 0;
border-top: 1px solid #eee;
background: white;
text-align: left;
padding: 8px 14px;
cursor: pointer;
}
.doc:hover,
.doc.active {
background: #e9f1ff;
}
.details {
padding: 14px;
}
.field {
margin-bottom: 13px;
}
.field b {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.viewer {
position: relative;
background: #555;
}
.viewer iframe {
width: 100%;
height: 100%;
border: 0;
background: white;
}
.viewer #pdfMessage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
pointer-events: none;
}
.viewer.loaded #pdfMessage {
display: none;
}
dialog {
width: min(720px,90vw);
}
dialog label {
display: block;
margin: 12px 0;
font-weight: 600;
}
dialog input {
display: block;
width: 100%;
padding: 8px;
margin-top: 5px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.hint {
color: #666;
}
.error {
color: #a40000;
}
.muted {
color: #777;
}
.error-banner { padding: 10px 16px; background: #fff1f1; border-bottom: 1px solid #c62828; color: #9b1c1c; white-space: pre-wrap; }
.dialog-error { margin-top: 12px; padding: 10px; border: 1px solid #c62828; background: #fff1f1; color: #9b1c1c; white-space: pre-wrap; }
#pdfMessage { white-space: pre-wrap; padding: 20px; color: #8a1c1c; }
.error-banner {
padding: 10px 16px;
background: #fff1f1;
border-bottom: 1px solid #c62828;
color: #9b1c1c;
white-space: pre-wrap;
}
.dialog-error {
margin-top: 12px;
padding: 10px;
border: 1px solid #c62828;
background: #fff1f1;
color: #9b1c1c;
white-space: pre-wrap;
}
#pdfMessage {
white-space: pre-wrap;
padding: 20px;
color: #8a1c1c;
}
.person-title {
color: #1a56db;
font-size: 15px;
}
.field {
margin-bottom: 0;
padding: 7px 6px;
border-radius: 3px;
}
.field b {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 2px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.field.row-even {
background: #fff;
}
.field.row-odd {
background: #eef2f6;
}
hr.field-sep {
border: none;
border-top: 2px solid #9ca3af;
margin: 14px 0;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 8px;
font-weight: 400 !important;
}
.checkbox-label input {
display: inline;
width: auto;
margin-top: 0;
}