Initial portal commit: landing + 9 AI-powered apps
Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat
This commit is contained in:
264
contract-check/static/app.js
Normal file
264
contract-check/static/app.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// Contract check frontend
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sections = {
|
||||
setup: $("s-setup"),
|
||||
processing: $("s-processing"),
|
||||
results: $("s-results"),
|
||||
};
|
||||
const show = (name) => {
|
||||
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
|
||||
};
|
||||
|
||||
let state = {
|
||||
jobId: null,
|
||||
checklist: [],
|
||||
analysis: null,
|
||||
pendingFile: null, // File chosen by user but not yet uploaded
|
||||
};
|
||||
let nextCustomId = 1;
|
||||
|
||||
// ── Load default checklist ──
|
||||
async function loadChecklist() {
|
||||
try {
|
||||
const r = await fetch("/api/checklist");
|
||||
const data = await r.json();
|
||||
state.checklist = (data.items || []).map((it) => ({
|
||||
...it,
|
||||
checked: it.default !== false,
|
||||
}));
|
||||
renderChecklist();
|
||||
} catch (err) {
|
||||
alert("Nepodařilo se načíst kontrolní seznam: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChecklist() {
|
||||
const c = $("checklist");
|
||||
c.innerHTML = "";
|
||||
for (const item of state.checklist) {
|
||||
const div = document.createElement("label");
|
||||
div.className = "checklist-item" + (item.custom ? " custom" : "");
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" ${item.checked ? "checked" : ""}>
|
||||
<div class="checklist-item-body">
|
||||
<div class="checklist-item-label">${escapeHtml(item.label)}</div>
|
||||
${item.hint ? `<div class="checklist-item-hint">${escapeHtml(item.hint)}</div>` : ""}
|
||||
</div>
|
||||
${item.custom ? `<button class="checklist-remove" type="button" title="Odstranit">×</button>` : ""}
|
||||
`;
|
||||
const cb = div.querySelector("input");
|
||||
cb.addEventListener("change", () => {
|
||||
item.checked = cb.checked;
|
||||
updateRunButton();
|
||||
});
|
||||
const rm = div.querySelector(".checklist-remove");
|
||||
if (rm) {
|
||||
rm.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
state.checklist = state.checklist.filter((x) => x.id !== item.id);
|
||||
renderChecklist();
|
||||
updateRunButton();
|
||||
});
|
||||
}
|
||||
c.appendChild(div);
|
||||
}
|
||||
updateRunButton();
|
||||
}
|
||||
|
||||
$("check-all-btn").addEventListener("click", () => {
|
||||
for (const it of state.checklist) it.checked = true;
|
||||
renderChecklist();
|
||||
});
|
||||
$("uncheck-all-btn").addEventListener("click", () => {
|
||||
for (const it of state.checklist) it.checked = false;
|
||||
renderChecklist();
|
||||
});
|
||||
|
||||
$("add-item-form").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const input = $("add-item-input");
|
||||
const label = input.value.trim();
|
||||
if (!label) return;
|
||||
state.checklist.push({
|
||||
id: `custom_${nextCustomId++}`,
|
||||
label,
|
||||
hint: "",
|
||||
checked: true,
|
||||
custom: true,
|
||||
});
|
||||
input.value = "";
|
||||
renderChecklist();
|
||||
});
|
||||
|
||||
// ── File selection (just stores the file, doesn't upload yet) ──
|
||||
const fileInput = $("file-input");
|
||||
const dropZone = $("drop-zone");
|
||||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", (e) => e.target.files[0] && setFile(e.target.files[0]));
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && setFile(e.dataTransfer.files[0]));
|
||||
|
||||
function setFile(file) {
|
||||
state.pendingFile = file;
|
||||
$("file-info-name").textContent = file.name;
|
||||
$("file-info").classList.remove("hidden");
|
||||
dropZone.classList.add("compact");
|
||||
updateRunButton();
|
||||
}
|
||||
|
||||
$("file-info-clear").addEventListener("click", () => {
|
||||
state.pendingFile = null;
|
||||
fileInput.value = "";
|
||||
$("file-info").classList.add("hidden");
|
||||
dropZone.classList.remove("compact");
|
||||
updateRunButton();
|
||||
});
|
||||
|
||||
function updateRunButton() {
|
||||
const hasFile = !!state.pendingFile;
|
||||
const selectedCount = state.checklist.filter((it) => it.checked).length;
|
||||
const btn = $("run-btn");
|
||||
const hint = $("run-hint");
|
||||
// run-hint only exists after a file is selected (lives in the file-info strip)
|
||||
if (!hasFile) {
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (selectedCount === 0) {
|
||||
btn.disabled = true;
|
||||
if (hint) hint.textContent = "Vyberte alespoň jednu položku ke kontrole níže.";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
if (hint) hint.textContent = `Připraveno spustit kontrolu ${selectedCount} bodů.`;
|
||||
}
|
||||
}
|
||||
|
||||
$("run-btn").addEventListener("click", async () => {
|
||||
if (!state.pendingFile) return;
|
||||
const selected = state.checklist.filter((it) => it.checked);
|
||||
if (!selected.length) return;
|
||||
show("processing");
|
||||
$("processing-title").textContent = "Nahrávám smlouvu…";
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", state.pendingFile);
|
||||
const ur = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
if (!ur.ok) throw new Error((await ur.json()).detail || ur.statusText);
|
||||
const upJson = await ur.json();
|
||||
state.jobId = upJson.job_id;
|
||||
|
||||
$("processing-title").textContent = `Analyzuji smlouvu (${selected.length} bodů)…`;
|
||||
const ar = await fetch(`/api/analyze/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: selected.map((it) => ({
|
||||
id: it.id,
|
||||
label: it.label,
|
||||
hint: it.hint || "",
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!ar.ok) throw new Error((await ar.json()).detail || ar.statusText);
|
||||
state.analysis = await ar.json();
|
||||
renderResults();
|
||||
show("results");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("setup");
|
||||
}
|
||||
});
|
||||
|
||||
function renderResults() {
|
||||
const a = state.analysis;
|
||||
const items = a.items || [];
|
||||
const usedOcr = !!a._used_ocr;
|
||||
const counts = items.reduce((acc, it) => {
|
||||
acc[it.status || "warning"] = (acc[it.status || "warning"] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
$("results-meta").textContent =
|
||||
`Vyhodnoceno ${items.length} bodů. ` +
|
||||
`OK: ${counts.ok || 0}, Pozor: ${counts.warning || 0}, ` +
|
||||
`Problém: ${counts.problem || 0}, Chybí: ${counts.missing || 0}.`;
|
||||
|
||||
const riskMap = {
|
||||
low: ["Nízké riziko", "risk-low"],
|
||||
medium: ["Střední riziko", "risk-medium"],
|
||||
high: ["Vysoké riziko", "risk-high"],
|
||||
};
|
||||
const r = riskMap[a.risk_level] || ["—", ""];
|
||||
const overall = $("overall-card");
|
||||
const ocrNotice = usedOcr
|
||||
? `<div class="ocr-notice">⚙ Smlouva neměla textovou vrstvu — použito OCR (rozpoznávání textu z obrazu). Stažené PDF bude obsahovat jen souhrnnou stránku, bez zvýraznění v původním textu (kvalita OCR neumožňuje spolehlivé vyhledání citací).</div>`
|
||||
: "";
|
||||
overall.innerHTML = `
|
||||
<div class="risk-badge ${r[1]}">${r[0]}</div>
|
||||
<div class="overall-text">
|
||||
${escapeHtml(a.overall_summary || "")}
|
||||
${ocrNotice}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const findings = $("findings");
|
||||
findings.innerHTML = "";
|
||||
const labelMap = { ok: "OK", warning: "POZOR", problem: "PROBLÉM", missing: "CHYBÍ" };
|
||||
// Sort: problem > warning > missing > ok
|
||||
const order = { problem: 0, warning: 1, missing: 2, ok: 3 };
|
||||
const sorted = [...items].sort((a, b) =>
|
||||
(order[a.status] ?? 9) - (order[b.status] ?? 9)
|
||||
);
|
||||
for (const it of sorted) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `finding status-${it.status || "warning"}`;
|
||||
const excerptsHtml = (it.excerpts || []).map((ex) => `
|
||||
<div class="excerpt">
|
||||
<div class="excerpt-text">
|
||||
${ex.page ? `<span class="excerpt-page">str. ${ex.page}</span>` : ""}
|
||||
<span>„${escapeHtml(ex.text || "")}"</span>
|
||||
</div>
|
||||
${ex.comment ? `<div class="excerpt-comment">${escapeHtml(ex.comment)}</div>` : ""}
|
||||
</div>
|
||||
`).join("");
|
||||
div.innerHTML = `
|
||||
<div class="finding-header">
|
||||
<span class="finding-status">${labelMap[it.status] || it.status || ""}</span>
|
||||
<span class="finding-title">${escapeHtml(it.title || it.label || it.id)}</span>
|
||||
</div>
|
||||
${it.summary ? `<p class="finding-summary">${escapeHtml(it.summary)}</p>` : ""}
|
||||
${excerptsHtml ? `<div class="finding-excerpts">${excerptsHtml}</div>` : ""}
|
||||
`;
|
||||
findings.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
$("download-pdf-btn").addEventListener("click", () => {
|
||||
if (!state.jobId) return;
|
||||
window.location.href = `/api/annotated/${state.jobId}`;
|
||||
});
|
||||
|
||||
$("restart-btn").addEventListener("click", () => {
|
||||
state = {
|
||||
jobId: null,
|
||||
checklist: state.checklist,
|
||||
analysis: null,
|
||||
pendingFile: null,
|
||||
};
|
||||
fileInput.value = "";
|
||||
$("file-info").classList.add("hidden");
|
||||
dropZone.classList.remove("compact");
|
||||
show("setup");
|
||||
updateRunButton();
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
loadChecklist();
|
||||
})();
|
||||
Reference in New Issue
Block a user