Files
bizmatch-qc-desktop/web/app.js
2026-07-15 19:34:13 -05:00

286 lines
8.4 KiB
JavaScript

let state;
let groups = [];
let selectedIndex = -1;
let currentPdfKey = null;
let loadRequestId = 0;
const people = document.querySelector("#people");
const fields = document.querySelector("#fields");
const viewerSection = document.querySelector(".viewer");
const pdfFrame = document.querySelector("#pdfFrame");
const pdfMessage = document.querySelector("#pdfMessage");
const status = document.querySelector("#status");
const search = document.querySelector("#search");
const errorBanner = document.querySelector("#errorBanner");
const pdfViewer = new NativePdfViewer(pdfFrame, viewerSection);
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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
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);
});
return [...map.values()].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 openDocument(index, { force = false } = {}) {
const doc = state.documents[index];
if (!doc) return;
const key = `${doc._letter}/${doc.file_name}`;
// Deduplicate: ignore click on already-loaded document unless forced
if (!force && key === currentPdfKey) {
return;
}
// Mark as loading this document
currentPdfKey = key;
selectedIndex = index;
renderList();
// Update detail panel
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("")
}
`;
pdfMessage.hidden = false;
pdfMessage.textContent = "Loading PDF\u2026";
const reqId = "pdf-" + (++loadRequestId);
console.log(`[BizMatch QC] Native PDF prepare: ${reqId} (${key})`);
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] Native PDF prepare: ${prepareMs}ms, cache ${data.cacheStatus}`,
);
if (key !== currentPdfKey) {
// A newer document was selected while preparing
return;
}
await pdfViewer.load(data.url, data.size, key);
pdfMessage.hidden = true;
console.log(`[BizMatch QC] Native PDF loaded: ${reqId}`);
} catch (error) {
if (key !== currentPdfKey) return;
const message = error instanceof Error ? error.message : String(error);
pdfMessage.textContent = `Cannot open PDF: ${message}`;
pdfMessage.hidden = false;
}
}
people.addEventListener("click", (event) => {
const button = event.target.closest("[data-index]");
if (button) openDocument(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;
openDocument(
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();
groups = buildGroups(state.documents);
showError(state.loadError || "");
renderList();
if (state.documents.length) openDocument(0);
} catch (error) {
showError(error instanceof Error ? error.message : String(error));
}
}
void load();