feat: add editable document type with autocomplete suggestions

This commit is contained in:
Sean McElwain 2026-04-07 12:49:06 -05:00
parent aa7f8d6a54
commit f1df9c29bb
2 changed files with 109 additions and 0 deletions

View File

@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, Form, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import distinct
from sqlalchemy.orm import Session, selectinload
from app.db.deps import get_db
@ -257,6 +258,22 @@ def _apply_reviewed_lines_to_layout(base_layout: dict | None, reviewed_text: str
return new_layout
def _get_existing_document_types(db: Session) -> list[str]:
rows = (
db.query(distinct(Document.document_type))
.filter(Document.document_type.isnot(None))
.order_by(Document.document_type.asc())
.all()
)
values: list[str] = []
for row in rows:
value = row[0]
if value:
values.append(str(value))
return values
def _get_queue_navigation(db: Session, document: Document) -> dict:
active_docs = (
db.query(Document)
@ -458,6 +475,23 @@ def list_documents(
)
@router.post("/{document_id}/save-document-type", response_class=RedirectResponse)
def save_document_type_route(
document_id: str,
document_type: str = Form(""),
db: Session = Depends(get_db),
):
document = db.query(Document).filter(Document.document_id == document_id).first()
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
document.document_type = document_type.strip() or None
db.commit()
return RedirectResponse(url=f"/documents/{document.document_id}", status_code=303)
@router.post("/{document_id}/rerun-ocr", response_class=RedirectResponse)
def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
document = db.query(Document).filter(Document.document_id == document_id).first()
@ -721,6 +755,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
selected_preset = _get_preset_by_id(db, preset_id)
all_presets = _get_all_presets(db)
existing_document_types = _get_existing_document_types(db)
extracted_form = _extracted_field_form_values(document, request)
additional_form = _additional_field_form_values(document, selected_preset)
@ -762,6 +797,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"current_additional": current_additional,
"presets": all_presets,
"selected_preset_id": preset_id,
"existing_document_types": existing_document_types,
"active_tab": active_tab,
"active_page": "documents",
},

View File

@ -54,6 +54,26 @@
</form>
</div>
<form method="post" action="/documents/{{ document.document_id }}/save-document-type" style="margin-top: 1rem;">
<div class="form-grid">
<div class="form-field" style="position: relative;">
<label for="document_type_input">Document type</label>
<input
id="document_type_input"
type="text"
name="document_type"
value="{{ document.document_type or '' }}"
placeholder="receipt"
autocomplete="off"
>
<div id="document-type-suggestions" style="display:none; position:absolute; top:100%; left:0; right:0; z-index:20; background:#fff; border:1px solid #d7dce5; border-radius:12px; margin-top:0.35rem; max-height:220px; overflow-y:auto; box-shadow:0 10px 24px rgba(15,23,42,0.10);"></div>
</div>
</div>
<div class="button-row" style="margin-top: 0.75rem;">
<button type="submit">Save document type</button>
</div>
</form>
<div class="queue-nav-row">
{% if prev_doc %}
<a class="button-link" href="/documents/{{ prev_doc.document_id }}">← Previous</a>
@ -432,6 +452,59 @@
syncScroll();
}
})();
const documentTypeInput = document.getElementById("document_type_input");
const documentTypeSuggestions = document.getElementById("document-type-suggestions");
const existingDocumentTypes = {{ existing_document_types|tojson }};
if (documentTypeInput && documentTypeSuggestions) {
function renderDocumentTypeSuggestions() {
const value = (documentTypeInput.value || "").trim().toLowerCase();
let matches = existingDocumentTypes.filter(function (item) {
return item && (!value || item.toLowerCase().includes(value));
});
if (value) {
matches = matches.filter(function (item) {
return item.toLowerCase() !== value;
});
}
matches = matches.slice(0, 8);
if (!matches.length) {
documentTypeSuggestions.style.display = "none";
documentTypeSuggestions.innerHTML = "";
return;
}
documentTypeSuggestions.innerHTML = matches.map(function (item) {
return '<button type="button" class="doc-type-option" data-value="' + item.replace(/"/g, '&quot;') + '" style="display:block; width:100%; text-align:left; padding:0.7rem 0.85rem; border:0; background:#fff; cursor:pointer;">' + item + '</button>';
}).join("");
documentTypeSuggestions.style.display = "block";
documentTypeSuggestions.querySelectorAll(".doc-type-option").forEach(function (btn) {
btn.addEventListener("click", function () {
documentTypeInput.value = btn.getAttribute("data-value") || "";
documentTypeSuggestions.style.display = "none";
documentTypeSuggestions.innerHTML = "";
documentTypeInput.focus();
});
});
}
documentTypeInput.addEventListener("input", renderDocumentTypeSuggestions);
documentTypeInput.addEventListener("focus", renderDocumentTypeSuggestions);
document.addEventListener("click", function (e) {
if (!documentTypeSuggestions.contains(e.target) && e.target !== documentTypeInput) {
documentTypeSuggestions.style.display = "none";
}
});
}
</script>
</body>
</html>