init
This commit is contained in:
4
.bizmatch-qc.json
Normal file
4
.bizmatch-qc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"jsonPath": "/home/aknuth/git/ai-bayarea/buyers_vision.json",
|
||||||
|
"pdfBaseDirectory": "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z/"
|
||||||
|
}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
tests
|
||||||
|
sample-data
|
||||||
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# BizMatch QC Desktop 0.1.2
|
||||||
|
|
||||||
|
Desktop quality-control viewer for BizMatch buyer documents.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deno task dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **Settings** and enter:
|
||||||
|
|
||||||
|
- the absolute path to the combined `buyers_vision.json`
|
||||||
|
- the absolute PDF base directory
|
||||||
|
|
||||||
|
The final PDF path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PDF base directory / _letter / file_name
|
||||||
|
```
|
||||||
|
|
||||||
|
Spaces and apostrophes in paths are supported. Settings are validated before they replace the current data. Errors are displayed in the Settings dialog and written to the terminal.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
Added Email, State, Notes Page, Buyer Info Page, and CA Page to the QC field panel.
|
||||||
21
deno.json
Normal file
21
deno.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "bizmatch-qc-desktop",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"exports": "./main.ts",
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno desktop --hmr --backend cef --allow-read --allow-write main.ts",
|
||||||
|
"start": "deno desktop --backend cef --allow-read --allow-write main.ts",
|
||||||
|
"test": "deno test --allow-read tests/"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"app": {
|
||||||
|
"name": "BizMatch QC",
|
||||||
|
"identifier": "net.bizmatch.qc"
|
||||||
|
},
|
||||||
|
"backend": "cef",
|
||||||
|
"output": {
|
||||||
|
"windows": "./dist/BizMatch-QC",
|
||||||
|
"linux": "./dist/bizmatch-qc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
main.ts
Normal file
188
main.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { dirname, fromFileUrl, join } from "jsr:@std/path";
|
||||||
|
import { validateDocuments } from "./src/data.ts";
|
||||||
|
import { resolvePdfPath } from "./src/paths.ts";
|
||||||
|
import type { BuyerDocument } from "./src/types.ts";
|
||||||
|
import INDEX_HTML from "./web/index.html" with { type: "text" };
|
||||||
|
import APP_JS from "./web/app.js" with { type: "text" };
|
||||||
|
import STYLES_CSS from "./web/styles.css" with { type: "text" };
|
||||||
|
import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with { type: "json" };
|
||||||
|
|
||||||
|
const ROOT = dirname(fromFileUrl(import.meta.url));
|
||||||
|
const CONFIG_PATH = join(Deno.cwd(), ".bizmatch-qc.json");
|
||||||
|
|
||||||
|
type Config = { jsonPath: string; pdfBaseDirectory: string };
|
||||||
|
type LoadResult = { documents: BuyerDocument[]; source: "sample" | "file" };
|
||||||
|
|
||||||
|
let config: Config = await readConfig();
|
||||||
|
let documents: BuyerDocument[] = [];
|
||||||
|
let loadError = "";
|
||||||
|
let dataSource: "sample" | "file" = "sample";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await loadDocuments(config.jsonPath);
|
||||||
|
documents = loaded.documents;
|
||||||
|
dataSource = loaded.source;
|
||||||
|
} catch (error) {
|
||||||
|
loadError = errorMessage(error);
|
||||||
|
console.error(`[BizMatch QC] Initial data load failed: ${loadError}`);
|
||||||
|
documents = validateDocuments(SAMPLE_DOCUMENTS);
|
||||||
|
dataSource = "sample";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfig(): Promise<Config> {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
jsonPath: "",
|
||||||
|
pdfBaseDirectory: "",
|
||||||
|
...JSON.parse(await Deno.readTextFile(CONFIG_PATH)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { jsonPath: "", pdfBaseDirectory: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocuments(jsonPath: string): Promise<LoadResult> {
|
||||||
|
if (!jsonPath.trim()) {
|
||||||
|
return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await Deno.readTextFile(jsonPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { documents: validateDocuments(parsed), source: "file" };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`JSON validation failed for "${jsonPath}": ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(next: Config): Promise<void> {
|
||||||
|
const normalized: Config = {
|
||||||
|
jsonPath: next.jsonPath.trim(),
|
||||||
|
pdfBaseDirectory: next.pdfBaseDirectory.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load first. Never replace valid in-memory data with an empty list on failure.
|
||||||
|
const loaded = await loadDocuments(normalized.jsonPath);
|
||||||
|
|
||||||
|
if (normalized.pdfBaseDirectory) {
|
||||||
|
try {
|
||||||
|
const stat = await Deno.stat(normalized.pdfBaseDirectory);
|
||||||
|
if (!stat.isDirectory) throw new Error("Path exists but is not a directory.");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Cannot access PDF base directory "${normalized.pdfBaseDirectory}": ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Deno.writeTextFile(CONFIG_PATH, JSON.stringify(normalized, null, 2));
|
||||||
|
config = normalized;
|
||||||
|
documents = loaded.documents;
|
||||||
|
dataSource = loaded.source;
|
||||||
|
loadError = "";
|
||||||
|
console.log(`[BizMatch QC] Loaded ${documents.length} documents from ${dataSource === "sample" ? "embedded sample data" : normalized.jsonPath}.`);
|
||||||
|
if (normalized.pdfBaseDirectory) {
|
||||||
|
console.log(`[BizMatch QC] PDF base directory: ${normalized.pdfBaseDirectory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(data: unknown, status = 200) {
|
||||||
|
return Response.json(data, {
|
||||||
|
status,
|
||||||
|
headers: { "cache-control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticFile(pathname: string): Response {
|
||||||
|
const files: Record<string, { body: string; type: string }> = {
|
||||||
|
"/": { body: INDEX_HTML, type: "text/html; charset=utf-8" },
|
||||||
|
"/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" },
|
||||||
|
"/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" },
|
||||||
|
"/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" },
|
||||||
|
};
|
||||||
|
const file = files[pathname];
|
||||||
|
if (!file) return new Response("Not found", { status: 404 });
|
||||||
|
return new Response(file.body, {
|
||||||
|
headers: { "content-type": file.type, "cache-control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/api/state" && request.method === "GET") {
|
||||||
|
return json({ config, documents, loadError, dataSource });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/config" && request.method === "POST") {
|
||||||
|
try {
|
||||||
|
const body = await request.json() as Config;
|
||||||
|
await saveConfig({
|
||||||
|
jsonPath: body.jsonPath ?? "",
|
||||||
|
pdfBaseDirectory: body.pdfBaseDirectory ?? "",
|
||||||
|
});
|
||||||
|
return json({ ok: true, count: documents.length, dataSource });
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
loadError = message;
|
||||||
|
console.error(`[BizMatch QC] Settings rejected: ${message}`);
|
||||||
|
return json({ error: message }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/pdf" && request.method === "GET") {
|
||||||
|
const index = Number(url.searchParams.get("index"));
|
||||||
|
const doc = documents[index];
|
||||||
|
if (!Number.isInteger(index) || !doc) {
|
||||||
|
return json({ error: "Unknown document index." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = resolvePdfPath(config.pdfBaseDirectory, doc);
|
||||||
|
const stat = await Deno.stat(path);
|
||||||
|
if (!stat.isFile) throw new Error("Resolved path is not a file.");
|
||||||
|
const file = await Deno.open(path, { read: true });
|
||||||
|
return new Response(file.readable, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/pdf",
|
||||||
|
"content-length": String(stat.size),
|
||||||
|
"content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(doc.file_name)}`,
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
console.error(`[BizMatch QC] PDF open failed for "${doc.file_name}": ${message}`);
|
||||||
|
return json({ error: message }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return staticFile(url.pathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[BizMatch QC] Listening on http://127.0.0.1:42469/`);
|
||||||
|
console.log(`[BizMatch QC] Config file: ${CONFIG_PATH}`);
|
||||||
|
console.log(`[BizMatch QC] Current data source: ${dataSource === "sample" ? "embedded sample data" : config.jsonPath}`);
|
||||||
|
console.log(`[BizMatch QC] Documents loaded: ${documents.length}`);
|
||||||
|
if (loadError) console.error(`[BizMatch QC] ${loadError}`);
|
||||||
|
|
||||||
|
const win = new Deno.BrowserWindow({
|
||||||
|
title: "BizMatch QC",
|
||||||
|
width: 1500,
|
||||||
|
height: 950,
|
||||||
|
});
|
||||||
|
win.show();
|
||||||
45
src/data.ts
Normal file
45
src/data.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { BuyerDocument, PersonGroup } from "./types.ts";
|
||||||
|
|
||||||
|
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||||
|
const normalize = (value: unknown) => text(value).toLocaleLowerCase();
|
||||||
|
|
||||||
|
export function validateDocuments(value: unknown): BuyerDocument[] {
|
||||||
|
if (!Array.isArray(value)) throw new Error("JSON root must be an array.");
|
||||||
|
return value.map((item, index) => {
|
||||||
|
if (!item || typeof item !== "object") throw new Error(`Record ${index + 1} is not an object.`);
|
||||||
|
const doc = item as Partial<BuyerDocument>;
|
||||||
|
if (!text(doc.file_name) || !text(doc.name_from_filename) || !text(doc._letter)) {
|
||||||
|
throw new Error(`Record ${index + 1} is missing file_name, name_from_filename, or _letter.`);
|
||||||
|
}
|
||||||
|
return doc as BuyerDocument;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
||||||
|
const groups = new Map<string, BuyerDocument[]>();
|
||||||
|
for (const doc of documents) {
|
||||||
|
const key = doc.name_from_filename.trim();
|
||||||
|
const bucket = groups.get(key) ?? [];
|
||||||
|
bucket.push(doc);
|
||||||
|
groups.set(key, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.entries()]
|
||||||
|
.map(([key, docs]) => {
|
||||||
|
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
|
const preferred = docs.find((d) => text(d.prospective_buyer))?.prospective_buyer;
|
||||||
|
const searchText = normalize([
|
||||||
|
key,
|
||||||
|
preferred,
|
||||||
|
...docs.flatMap((d) => [d.types_of_business_raw, d.notes_business_raw, d.address]),
|
||||||
|
].filter(Boolean).join("\n"));
|
||||||
|
return { key, displayName: text(preferred) || key, documents: docs, searchText };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGroups(groups: PersonGroup[], query: string): PersonGroup[] {
|
||||||
|
const terms = normalize(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (!terms.length) return groups;
|
||||||
|
return groups.filter((group) => terms.every((term) => group.searchText.includes(term)));
|
||||||
|
}
|
||||||
13
src/paths.ts
Normal file
13
src/paths.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { isAbsolute, join, normalize, relative } from "jsr:@std/path";
|
||||||
|
import type { BuyerDocument } from "./types.ts";
|
||||||
|
|
||||||
|
export function resolvePdfPath(baseDirectory: string, doc: BuyerDocument): string {
|
||||||
|
if (!baseDirectory.trim()) throw new Error("PDF base directory is not configured.");
|
||||||
|
if (!isAbsolute(baseDirectory)) throw new Error("PDF base directory must be absolute.");
|
||||||
|
|
||||||
|
const base = normalize(baseDirectory);
|
||||||
|
const full = normalize(join(base, doc._letter, doc.file_name));
|
||||||
|
const rel = relative(base, full);
|
||||||
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Resolved PDF path escapes the base directory.");
|
||||||
|
return full;
|
||||||
|
}
|
||||||
35
src/types.ts
Normal file
35
src/types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface BuyerDocument {
|
||||||
|
file_name: string;
|
||||||
|
name_from_filename: string;
|
||||||
|
_letter: string;
|
||||||
|
prospective_buyer?: string | null;
|
||||||
|
name_company?: string | null;
|
||||||
|
company?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
cell?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
how_did_you_hear?: string | null;
|
||||||
|
interested_in_updates?: string | null;
|
||||||
|
types_of_business_raw?: string | null;
|
||||||
|
notes_business_raw?: string | null;
|
||||||
|
background_experience?: string | null;
|
||||||
|
total_purchase_price?: string | null;
|
||||||
|
down_payment?: string | null;
|
||||||
|
down_payment_raw?: string | null;
|
||||||
|
date_of_introduction?: string | null;
|
||||||
|
_doc_type?: string | null;
|
||||||
|
_pages_total?: number | null;
|
||||||
|
_notes_page?: number | null;
|
||||||
|
_info_page?: number | null;
|
||||||
|
_ca_page?: number | null;
|
||||||
|
_vision_error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonGroup {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
documents: BuyerDocument[];
|
||||||
|
searchText: string;
|
||||||
|
}
|
||||||
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