init
This commit is contained in:
208
web/app.js
Normal file
208
web/app.js
Normal file
@@ -0,0 +1,208 @@
|
||||
let state;
|
||||
let groups = [];
|
||||
let selectedIndex = -1;
|
||||
let currentPdfUrl = "";
|
||||
|
||||
const people = document.querySelector("#people");
|
||||
const fields = document.querySelector("#fields");
|
||||
const pdf = document.querySelector("#pdf");
|
||||
const viewer = document.querySelector(".viewer");
|
||||
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"],
|
||||
["Phone", "phone"],
|
||||
["Cell", "cell"],
|
||||
["Email", "email"],
|
||||
["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"],
|
||||
["Date of Introduction", "date_of_introduction"],
|
||||
["Down payment", "down_payment_raw"],
|
||||
["Total purchase price", "total_purchase_price"],
|
||||
["Notes Page", "_notes_page"],
|
||||
["Buyer Info Page", "_info_page"],
|
||||
["CA Page", "_ca_page"],
|
||||
];
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[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 · ${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) {
|
||||
if (currentPdfUrl) {
|
||||
URL.revokeObjectURL(currentPdfUrl);
|
||||
currentPdfUrl = "";
|
||||
}
|
||||
pdf.removeAttribute("src");
|
||||
viewer.classList.remove("loaded");
|
||||
pdfMessage.hidden = false;
|
||||
pdfMessage.textContent = "Loading PDF…";
|
||||
|
||||
try {
|
||||
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}.`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
currentPdfUrl = URL.createObjectURL(blob);
|
||||
pdf.src = currentPdfUrl;
|
||||
viewer.classList.add("loaded");
|
||||
pdfMessage.hidden = true;
|
||||
} catch (error) {
|
||||
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;
|
||||
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("")}
|
||||
`;
|
||||
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 settingsError = document.querySelector("#settingsError");
|
||||
const saveSettings = document.querySelector("#saveSettings");
|
||||
|
||||
document.querySelector("#settings").onclick = () => {
|
||||
jsonPath.value = state.config.jsonPath;
|
||||
pdfBase.value = state.config.pdfBaseDirectory;
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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) select(0);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
44
web/index.html
Normal file
44
web/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!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
|
||||
<input id="jsonPath" autocomplete="off">
|
||||
</label>
|
||||
<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>
|
||||
</html>
|
||||
5
web/styles.css
Normal file
5
web/styles.css
Normal file
@@ -0,0 +1,5 @@
|
||||
*{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; }
|
||||
Reference in New Issue
Block a user