feat: improve document detail UI and disable deprecated PDF save routes

This commit is contained in:
Sean McElwain 2026-04-28 22:25:53 -05:00
parent aecc7c5679
commit b1e059fe05
4 changed files with 202 additions and 93 deletions

View File

@ -959,38 +959,10 @@ def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
@router.post("/{document_id}/save-ocr-corrected-pdf", response_class=RedirectResponse) @router.post("/{document_id}/save-ocr-corrected-pdf", response_class=RedirectResponse)
def save_ocr_corrected_pdf(document_id: str, db: Session = Depends(get_db)): def save_ocr_corrected_pdf(document_id: str, db: Session = Depends(get_db)):
document = ( return RedirectResponse(
db.query(Document) url=f"/documents/{document_id}?error=deprecated_pdf_route_disabled&tab=ocr-review",
.options( status_code=303,
selectinload(Document.text_versions),
selectinload(Document.naming_fields),
selectinload(Document.extracted_fields),
selectinload(Document.additional_fields),
) )
.filter(Document.document_id == document_id)
.first()
)
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
save_root = get_default_save_root()
naming_row = document.naming_fields[0] if getattr(document, "naming_fields", None) else None
output_path = Path(
build_proposed_storage_path(
document=document,
save_root=save_root,
naming_row=naming_row,
)
)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
create_ocr_corrected_pdf_version(db, document, output_path=output_path_obj)
except Exception:
return RedirectResponse(url=f"/documents/{document.document_id}?error=save_ocr_corrected_failed", status_code=303)
return RedirectResponse(url=f"/documents/{document.document_id}?tab=ocr-review", status_code=303)
@router.post("/{document_id}/save-review-flags", response_class=RedirectResponse) @router.post("/{document_id}/save-review-flags", response_class=RedirectResponse)
@ -1109,39 +1081,10 @@ def save_pdf(document_id: str, output_path: str = Form(""), db: Session = Depend
@router.post("/{document_id}/save-field-enriched-pdf", response_class=RedirectResponse) @router.post("/{document_id}/save-field-enriched-pdf", response_class=RedirectResponse)
def save_field_enriched_pdf(document_id: str, db: Session = Depends(get_db)): def save_field_enriched_pdf(document_id: str, db: Session = Depends(get_db)):
document = ( return RedirectResponse(
db.query(Document) url=f"/documents/{document_id}?error=deprecated_pdf_route_disabled&tab=extracted-fields",
.options( status_code=303,
selectinload(Document.naming_fields),
selectinload(Document.extracted_fields),
selectinload(Document.additional_fields),
) )
.filter(Document.document_id == document_id)
.first()
)
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
save_root = get_default_save_root()
naming_row = document.naming_fields[0] if getattr(document, "naming_fields", None) else None
output_path = Path(
build_proposed_storage_path(
document=document,
save_root=save_root,
naming_row=naming_row,
)
)
output_path = output_path.with_name(
re.sub(r"_v\d+(?=\.[^.]+$)", "", output_path.name)
)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
create_field_enriched_pdf_version(db, document, output_path=output_path_obj)
except Exception as e:
return RedirectResponse(url=f"/documents/{document.document_id}?error=save_field_enriched_failed", status_code=303)
return RedirectResponse(url=f"/documents/{document.document_id}?tab=extracted-fields", status_code=303)
@router.post("/{document_id}/review-text", response_class=RedirectResponse) @router.post("/{document_id}/review-text", response_class=RedirectResponse)

View File

@ -5695,3 +5695,71 @@ table {
} }
/* ===== end polished login page ===== */ /* ===== end polished login page ===== */
/* ===== mobile split mode wide canvas ===== */
@media (max-width: 900px) {
body.detail-mode-split .document-detail-grid,
body.detail-mode-split .detail-layout-grid,
body.detail-mode-split .detail-main-grid {
display: grid !important;
grid-template-columns: 1.2fr 0.8fr !important;
gap: 0.75rem !important;
width: 300vw !important;
min-width: 300vw !important;
max-width: 300vw !important;
align-items: start !important;
}
body.detail-mode-split .preview-card,
body.detail-mode-split .tabbed-review-card,
body.detail-mode-split .review-card,
body.detail-mode-split .document-preview-card,
body.detail-mode-split .document-review-card {
width: 100% !important;
max-width: none !important;
min-width: 0 !important;
}
body.detail-mode-split .main,
body.detail-mode-split .main-content {
overflow-x: auto !important;
}
}
/* ===== end mobile split mode wide canvas ===== */
.line-item-delete-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 1px solid #e7bcbc;
border-radius: 0.75rem;
background: #fff;
cursor: pointer;
}
.line-item-delete-btn:hover {
background: #fff5f5;
}
.line-item-delete-btn svg {
width: 1.25rem;
height: 1.25rem;
display: block;
}
.line-item-delete-btn .trash-tile {
fill: #fff;
stroke: #cf2e2e;
stroke-width: 1.6;
}
.line-item-delete-btn .trash-can {
fill: none;
stroke: #cf2e2e;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Document Processor{% endblock %}</title> <title>{% block title %}Document Processor{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css?v=154"> <link rel="stylesheet" href="/static/app.css?v=161">
<link rel="stylesheet" href="/static/app-shell.css?v=66"> <link rel="stylesheet" href="/static/app-shell.css?v=158">
</head> </head>
<body> <body>
<button id="mobile-nav-toggle" class="mobile-nav-toggle" type="button" aria-label="Toggle navigation"> <button id="mobile-nav-toggle" class="mobile-nav-toggle" type="button" aria-label="Toggle navigation">

View File

@ -79,6 +79,8 @@ document.addEventListener("DOMContentLoaded", () => {
<div class="success-message">Reviewed OCR saved.</div> <div class="success-message">Reviewed OCR saved.</div>
{% elif error == "rerun_ocr_failed" %} {% elif error == "rerun_ocr_failed" %}
<div class="error-box">OCR rerun failed.</div> <div class="error-box">OCR rerun failed.</div>
{% elif error == "deprecated_pdf_route_disabled" %}
<div class="error-box">This deprecated PDF save route has been disabled. Use Save Document instead.</div>
{% elif error == "save_field_enriched_failed" %} {% elif error == "save_field_enriched_failed" %}
<div class="error-box">Could not save field-enriched PDF.</div> <div class="error-box">Could not save field-enriched PDF.</div>
{% endif %} {% endif %}
@ -218,7 +220,7 @@ document.addEventListener("DOMContentLoaded", () => {
<section> <section>
<div class="card"> <div class="card">
<div class="right-pane-tabs"> <div class="right-pane-tabs">
<button class="tab-button{% if active_tab == 'ocr-review' %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button> <button class="tab-button{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button> <button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button> <button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
<button class="tab-button{% if active_tab == 'line-items' %} active{% endif %}" type="button" data-tab="line-items">Line Items</button> <button class="tab-button{% if active_tab == 'line-items' %} active{% endif %}" type="button" data-tab="line-items">Line Items</button>
@ -227,7 +229,7 @@ document.addEventListener("DOMContentLoaded", () => {
<button class="tab-button{% if active_tab == 'source-options' %} active{% endif %}" type="button" data-tab="source-options">Source Options</button> <button class="tab-button{% if active_tab == 'source-options' %} active{% endif %}" type="button" data-tab="source-options">Source Options</button>
</div> </div>
<div class="tab-panel{% if active_tab == 'ocr-review' %} active{% endif %}" data-panel="ocr-review"> <div class="tab-panel{% if active_tab in ['ocr-review', 'raw-ocr', 'source-options'] %} active{% endif %}" data-panel="ocr-review">
<div class="ocr-review-header-row"> <div class="ocr-review-header-row">
<h2 class="card-title">Reviewed OCR</h2> <h2 class="card-title">Reviewed OCR</h2>
<form method="post" action="/documents/{{ document.document_id }}/rerun-ocr" class="ocr-rerun-inline-form"> <form method="post" action="/documents/{{ document.document_id }}/rerun-ocr" class="ocr-rerun-inline-form">
@ -458,7 +460,7 @@ document.addEventListener("DOMContentLoaded", () => {
<form method="post" action="/documents/{{ document.document_id }}/save-line-items"> <form method="post" action="/documents/{{ document.document_id }}/save-line-items">
{% set base_count = line_items|length %} {% set base_count = line_items|length %}
{% set row_count = base_count + 3 if base_count > 0 else 12 %} {% set row_count = base_count if base_count > 0 else 0 %}
<input type="hidden" name="row_count" value="{{ row_count }}"> <input type="hidden" name="row_count" value="{{ row_count }}">
<div style="margin-bottom: 0.5rem;"> <div style="margin-bottom: 0.5rem;">
@ -484,8 +486,21 @@ document.addEventListener("DOMContentLoaded", () => {
<tbody> <tbody>
{% for i in range(row_count) %} {% for i in range(row_count) %}
{% set item = line_items[i] if i < line_items|length else None %} {% set item = line_items[i] if i < line_items|length else None %}
<tr> <tr class="line-item-row">
<td style="padding:0.35rem;">{{ i + 1 }}</td> <td style="padding:0.35rem; white-space:nowrap;">
<span class="line-item-row-number">{{ i + 1 }}</span>
<button type="button" onclick="removeLineItemRow(this)" aria-label="Remove line item" title="Remove line item" style="margin-left:0.45rem; padding:0; border:none; background:transparent; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; width:2.1rem; height:2.1rem; vertical-align:middle;">
<svg viewBox="0 0 24 24" aria-hidden="true" style="display:block; width:1.9rem; height:1.9rem;">
<rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="#ffffff" stroke="#d7dce5" stroke-width="1.2"></rect>
<path d="M8 7h8" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10 5h4" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M9 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M15 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</td>
<td style="padding:0.35rem;"><input type="date" name="entry_date_{{ i }}" value="{{ item.entry_date.isoformat() if item and item.entry_date else '' }}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="date" name="entry_date_{{ i }}" value="{{ item.entry_date.isoformat() if item and item.entry_date else '' }}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="description_{{ i }}" value="{{ item.description if item else '' }}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="description_{{ i }}" value="{{ item.description if item else '' }}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="quantity_{{ i }}" value="{{ (item.quantity | string | replace('.0000', '') | replace('.00', '')) if item and item.quantity is not none else '' }}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="quantity_{{ i }}" value="{{ (item.quantity | string | replace('.0000', '') | replace('.00', '')) if item and item.quantity is not none else '' }}" style="width:100%;"></td>
@ -506,17 +521,64 @@ document.addEventListener("DOMContentLoaded", () => {
</form> </form>
<script> <script>
function renumberLineItemRows(panel) {
const tbody = panel.querySelector("tbody");
const rowCountInput = panel.querySelector('input[name="row_count"]');
const rows = Array.from(tbody.querySelectorAll("tr.line-item-row, tr"));
rows.forEach((row, i) => {
const num = row.querySelector(".line-item-row-number");
if (num) {
num.textContent = String(i + 1);
} else if (row.cells && row.cells[0]) {
row.cells[0].textContent = String(i + 1);
}
row.querySelectorAll("input").forEach((input) => {
const oldName = input.getAttribute("name");
if (!oldName) return;
input.setAttribute("name", oldName.replace(/_\d+$/, `_${i}`));
});
});
rowCountInput.value = rows.length;
}
function removeLineItemRow(button) {
const panel = document.querySelector('[data-panel="line-items"]');
if (!panel) return;
const row = button.closest("tr");
if (!row) return;
row.remove();
renumberLineItemRows(panel);
}
function addRow() { function addRow() {
const panel = document.querySelector('[data-panel="line-items"]'); const panel = document.querySelector('[data-panel="line-items"]');
if (!panel) return; if (!panel) return;
const tbody = panel.querySelector("tbody"); const tbody = panel.querySelector("tbody");
const rowCountInput = panel.querySelector('input[name="row_count"]');
const i = tbody.querySelectorAll("tr").length; const i = tbody.querySelectorAll("tr").length;
const row = document.createElement("tr"); const row = document.createElement("tr");
row.className = "line-item-row";
row.innerHTML = ` row.innerHTML = `
<td style="padding:0.35rem;">${i + 1}</td> <td style="padding:0.35rem; white-space:nowrap;">
<span class="line-item-row-number">${i + 1}</span>
<button type="button" onclick="removeLineItemRow(this)" aria-label="Remove line item" title="Remove line item" style="margin-left:0.45rem; padding:0; border:none; background:transparent; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; width:2.1rem; height:2.1rem; vertical-align:middle;">
<svg viewBox="0 0 24 24" aria-hidden="true" style="display:block; width:1.9rem; height:1.9rem;">
<rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="#ffffff" stroke="#d7dce5" stroke-width="1.2"></rect>
<path d="M8 7h8" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10 5h4" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M9 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M15 9.25v6.25" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2" fill="none" stroke="#cf2e2e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</td>
<td style="padding:0.35rem;"><input type="date" name="entry_date_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="date" name="entry_date_${i}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="description_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="description_${i}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="quantity_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="quantity_${i}" style="width:100%;"></td>
@ -525,10 +587,21 @@ function addRow() {
<td style="padding:0.35rem;"><input type="text" name="tax_amount_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="tax_amount_${i}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="category_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="category_${i}" style="width:100%;"></td>
<td style="padding:0.35rem;"><input type="text" name="notes_${i}" style="width:100%;"></td> <td style="padding:0.35rem;"><input type="text" name="notes_${i}" style="width:100%;"></td>
<td style="padding:0.35rem; width:3.25rem; text-align:center;"><button type="button" class="line-item-delete-btn" onclick="deleteRow(this)" aria-label="Delete line item" title="Delete line item">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect class="trash-tile" x="1.5" y="1.5" width="21" height="21" rx="5"></rect>
<path class="trash-can" d="M8 7h8"></path>
<path class="trash-can" d="M10 5h4"></path>
<path class="trash-can" d="M9 9.25v6.25"></path>
<path class="trash-can" d="M12 9.25v6.25"></path>
<path class="trash-can" d="M15 9.25v6.25"></path>
<path class="trash-can" d="M7.5 7.5l.7 10.2a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.2"></path>
</svg>
</button></td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
rowCountInput.value = i + 1; renumberLineItemRows(panel);
} }
</script> </script>
</div> </div>
@ -585,40 +658,65 @@ function addRow() {
<select id="file_action_select" name="file_action"> <select id="file_action_select" name="file_action">
<option value="none" selected>No file change</option> <option value="none" selected>No file change</option>
<option value="revert_original">Revert to original file</option> <option value="revert_original">Revert to original file</option>
<option value="revert_current_saved">Revert to current saved version</option> <option value="revert_current_version">Revert to latest saved PDF version</option>
</select> </select>
</div> </div>
</div> </div>
<div class="card" style="padding:1rem;"> <div class="card" style="padding:1rem;">
<h3 style="margin-top:0;">Data Reset</h3> <h3 style="margin-top:0;">Restore from version history</h3>
<div class="data-reset-grid"> <div class="data-reset-grid">
<div class="data-reset-row"> <div class="data-reset-row">
<label for="ocr_action">OCR</label> <label for="ocr_restore_choice">OCR</label>
<select id="ocr_action" name="ocr_action"> <select id="ocr_restore_choice" name="ocr_restore_choice">
<option value="none" {% if selected_ocr_action == "none" %}selected{% endif %}>No change</option> <option value="none">No change</option>
<option value="reset" {% if selected_ocr_action == "reset" %}selected{% endif %}>Reset</option> <option value="original">Reset to original</option>
{% for tv in (document.text_versions | sort(attribute='version_number', reverse=True)) %}
<option value="version:{{ tv.version_number }}">
v{{ tv.version_number }} — {{ tv.version_type }} — {{ tv.created_at }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="data-reset-row"> <div class="data-reset-row">
<label for="extracted_action">Extracted fields</label> <label for="extracted_restore_choice">Extracted fields</label>
<select id="extracted_action" name="extracted_action"> <select id="extracted_restore_choice" name="extracted_restore_choice">
<option value="none" {% if selected_extracted_action == "none" %}selected{% endif %}>No change</option> <option value="none">No change</option>
<option value="reset" {% if selected_extracted_action == "reset" %}selected{% endif %}>Reset</option> <option value="original">Reset to original</option>
{% for v in (document.extracted_field_versions | sort(attribute='version_number', reverse=True)) %}
<option value="version:{{ v.version_number }}">
v{{ v.version_number }} — {{ v.created_at }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="data-reset-row"> <div class="data-reset-row">
<label for="additional_action">Additional fields</label> <label for="additional_restore_choice">Additional fields</label>
<select id="additional_action" name="additional_action"> <select id="additional_restore_choice" name="additional_restore_choice">
<option value="none" {% if selected_additional_action == "none" %}selected{% endif %}>No change</option> <option value="none">No change</option>
<option value="reset" {% if selected_additional_action == "reset" %}selected{% endif %}>Reset</option> <option value="original">Reset to original</option>
{% for v in (document.additional_field_versions | sort(attribute='version_number', reverse=True)) %}
<option value="version:{{ v.version_number }}">
v{{ v.version_number }} — {{ v.created_at }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="data-reset-row"> <div class="data-reset-row">
<label for="line_items_action">Line items</label> <label for="line_item_restore_choice">Line items</label>
<select id="line_items_action" name="line_items_action"> <select id="line_item_restore_choice" name="line_item_restore_choice">
<option value="none" {% if selected_line_items_action == "none" %}selected{% endif %}>No change</option> <option value="none">No change</option>
<option value="reset" {% if selected_line_items_action == "reset" %}selected{% endif %}>Reset</option> <option value="clear">Clear current line items</option>
{% if document.line_item_set_versions is defined %}
{% for v in (document.line_item_set_versions | sort(attribute='version_number', reverse=True)) %}
<option value="version:{{ v.version_number }}">
v{{ v.version_number }} — {{ v.created_at }}
</option>
{% endfor %}
{% endif %}
</select> </select>
</div> </div>
</div> </div>