362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
let state;
|
|
let groups = [];
|
|
let selectedIndex = -1;
|
|
let loadedIndex = -1; // which document the PDF viewer currently shows/loads
|
|
let pdfViewer = null;
|
|
let loadRequestId = 0;
|
|
|
|
const people = document.querySelector("#people");
|
|
const fields = document.querySelector("#fields");
|
|
const viewer = document.querySelector(".viewer");
|
|
const pdfContainer = document.querySelector("#pdfViewer");
|
|
const pdfMessage = document.querySelector("#pdfMessage");
|
|
const status = document.querySelector("#status");
|
|
const search = document.querySelector("#search");
|
|
const errorBanner = document.querySelector("#errorBanner");
|
|
|
|
const fieldDefs = [
|
|
["Name / Company", "name_company"],
|
|
["Prospective Buyer", "prospective_buyer"],
|
|
["Company", "company"],
|
|
null,
|
|
["Phone", "phone"],
|
|
["Cell", "cell"],
|
|
["Email", "email"],
|
|
null,
|
|
["Address", "address"],
|
|
["State", "state"],
|
|
null,
|
|
["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"],
|
|
null,
|
|
["Notes Page", "_notes_page"],
|
|
["Buyer Info Page", "_info_page"],
|
|
["CA Page", "_ca_page"],
|
|
];
|
|
|
|
const esc = (value) =>
|
|
String(value ?? "").replace(/[&<>"']/g, (char) =>
|
|
({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
})[char]);
|
|
|
|
// Normalized person key from the vision-extracted buyer name: lowercase,
|
|
// punctuation stripped, name tokens sorted so "Zahoor Bilal" and
|
|
// "Bilal Zahoor" compare equal.
|
|
function personKey(group) {
|
|
const name = group.docs.map((d) => d.prospective_buyer).find((v) =>
|
|
typeof v === "string" && v.trim()
|
|
);
|
|
if (!name) return null;
|
|
const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
.trim().split(/\s+/).filter(Boolean).sort();
|
|
return tokens.length ? tokens.join(" ") : null;
|
|
}
|
|
|
|
function buildGroups(docs) {
|
|
const map = new Map();
|
|
docs.forEach((doc, index) => {
|
|
const key = doc.name_from_filename;
|
|
const group = map.get(key) || {
|
|
key,
|
|
displayName: doc.prospective_buyer || key,
|
|
docs: [],
|
|
text: "",
|
|
};
|
|
group.docs.push({ ...doc, index });
|
|
group.text += " " + [
|
|
key,
|
|
doc.prospective_buyer,
|
|
doc.types_of_business_raw,
|
|
doc.notes_business_raw,
|
|
doc.address,
|
|
].filter(Boolean).join(" ");
|
|
map.set(key, group);
|
|
});
|
|
|
|
// Second pass: merge filename-based groups that refer to the same person
|
|
// according to the extracted prospective_buyer. This catches typos in the
|
|
// scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would
|
|
// otherwise show the same buyer twice. Groups without a prospective_buyer
|
|
// are never merged.
|
|
const byPerson = new Map();
|
|
const merged = [];
|
|
for (const group of map.values()) {
|
|
const pKey = personKey(group);
|
|
const target = pKey ? byPerson.get(pKey) : undefined;
|
|
if (target) {
|
|
target.docs.push(...group.docs);
|
|
target.text += " " + group.text;
|
|
continue;
|
|
}
|
|
if (pKey) byPerson.set(pKey, group);
|
|
merged.push(group);
|
|
}
|
|
for (const group of merged) {
|
|
group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
|
const preferred = group.docs.find((d) =>
|
|
typeof d.prospective_buyer === "string" && d.prospective_buyer.trim()
|
|
);
|
|
if (preferred) group.displayName = preferred.prospective_buyer;
|
|
}
|
|
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
|
|
function visibleGroups() {
|
|
const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
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 \u00b7 ${source}`;
|
|
status.className = "";
|
|
}
|
|
|
|
function showError(message) {
|
|
errorBanner.textContent = message;
|
|
errorBanner.hidden = !message;
|
|
}
|
|
|
|
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}">
|
|
${esc(doc.file_name)}
|
|
</button>`).join("")
|
|
}
|
|
</div>
|
|
`).join("");
|
|
updateStatus(shown.length);
|
|
}
|
|
|
|
async function loadPdf(index) {
|
|
const doc = state.documents[index];
|
|
if (!doc) return;
|
|
|
|
// Deduplicate: don't reload the document that is already shown.
|
|
// NOTE: must compare against loadedIndex, NOT selectedIndex --
|
|
// select() updates selectedIndex before calling loadPdf(), so a
|
|
// selectedIndex comparison is always true and blocks every reload.
|
|
if (index === loadedIndex && pdfViewer) {
|
|
return;
|
|
}
|
|
loadedIndex = index;
|
|
|
|
const previousZoom = pdfViewer ? pdfViewer.zoom : 1;
|
|
if (pdfViewer) {
|
|
pdfViewer.destroy();
|
|
}
|
|
pdfViewer = new PdfViewer(pdfContainer, { initialZoom: previousZoom });
|
|
viewer.classList.remove("loaded");
|
|
pdfMessage.hidden = false;
|
|
pdfMessage.textContent = "Loading PDF\u2026";
|
|
|
|
const reqId = "pdf-" + (++loadRequestId);
|
|
console.log(
|
|
`[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`,
|
|
);
|
|
console.log(`[BizMatch QC] PDF ${reqId} prepare requested`);
|
|
|
|
const startTime = performance.now();
|
|
try {
|
|
const response = await fetch("/api/pdf/prepare", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ index, requestId: reqId }),
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => ({}));
|
|
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const prepareMs = Math.round(performance.now() - startTime);
|
|
console.log(
|
|
`[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
|
);
|
|
|
|
if (index !== selectedIndex) {
|
|
// A newer selection happened while preparing, don't show stale result
|
|
return;
|
|
}
|
|
|
|
viewer.classList.add("loaded");
|
|
pdfMessage.hidden = true;
|
|
console.log(`[BizMatch QC] PDF ${reqId} viewer load started`);
|
|
await pdfViewer.load(data.url, data.size);
|
|
console.log(`[BizMatch QC] PDF ${reqId} rendering complete`);
|
|
} catch (error) {
|
|
if (index !== selectedIndex) return;
|
|
loadedIndex = -1; // allow retry: clicking the same document again reloads it
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
|
pdfMessage.hidden = false;
|
|
}
|
|
}
|
|
|
|
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)} \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();
|
|
}
|
|
|
|
people.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-index]");
|
|
if (button) select(Number(button.dataset.index));
|
|
});
|
|
|
|
search.addEventListener("input", renderList);
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
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 || "";
|
|
useAnonData.checked = !!state.config.useAnonymousData;
|
|
settingsError.hidden = true;
|
|
settingsError.textContent = "";
|
|
dialog.showModal();
|
|
};
|
|
|
|
saveSettings.onclick = async (event) => {
|
|
event.preventDefault();
|
|
saveSettings.disabled = true;
|
|
settingsError.hidden = true;
|
|
try {
|
|
const response = await fetch("/api/config", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
jsonPath: jsonPath.value,
|
|
pdfBaseDirectory: pdfBase.value,
|
|
useAnonymousData: useAnonData.checked,
|
|
}),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error || "Could not save settings.");
|
|
dialog.close();
|
|
await load();
|
|
} catch (error) {
|
|
settingsError.textContent = error instanceof Error
|
|
? error.message
|
|
: String(error);
|
|
settingsError.hidden = false;
|
|
} finally {
|
|
saveSettings.disabled = false;
|
|
}
|
|
};
|
|
|
|
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}.`);
|
|
}
|
|
state = await response.json();
|
|
loadedIndex = -1; // document set changed; index-based dedup is invalid now
|
|
groups = buildGroups(state.documents);
|
|
showError(state.loadError || "");
|
|
renderList();
|
|
if (state.documents.length) select(0);
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
void load();
|
|
|
|
// ---- Window size reporting ----
|
|
// The backend's native size APIs are unreliable (see main.ts), so the
|
|
// webview reports its own viewport size. The first report, sent shortly
|
|
// after startup, calibrates the decoration offset on the backend; later
|
|
// reports track user resizes.
|
|
let metricsTimer = null;
|
|
function reportWindowMetrics() {
|
|
fetch("/api/window-metrics", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
innerWidth: globalThis.innerWidth,
|
|
innerHeight: globalThis.innerHeight,
|
|
}),
|
|
}).catch(() => {});
|
|
}
|
|
globalThis.addEventListener("resize", () => {
|
|
clearTimeout(metricsTimer);
|
|
metricsTimer = setTimeout(reportWindowMetrics, 250);
|
|
});
|
|
setTimeout(reportWindowMetrics, 800); // calibration report
|