Add local files section to case view (research, drafts, proofread)

- New API endpoint /api/cases/{num}/local-files lists files from disk
- New API endpoint /api/cases/{num}/local-files/{folder}/{file} serves file content
- Case view now shows research/analysis files, proofread texts, and draft decisions
- Files are clickable and open in new tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:43:02 +00:00
parent 22e819363e
commit bfcbb6708a
2 changed files with 163 additions and 2 deletions

View File

@@ -565,6 +565,62 @@ async def api_learn(case_number: str):
return {"status": "final", "message": "לולאת למידה הופעלה — גרסה סופית נקלטה"} return {"status": "final", "message": "לולאת למידה הופעלה — גרסה סופית נקלטה"}
# ── Local files API — research, drafts, proofread ──
@app.get("/api/cases/{case_number}/local-files")
async def api_local_files(case_number: str):
"""List local files from case subdirectories (research, drafts, proofread)."""
case_dir = config.find_case_dir(case_number)
result = {}
for folder in ("research", "proofread"):
folder_path = case_dir / "documents" / folder
if folder_path.exists():
files = []
for f in sorted(folder_path.iterdir()):
if f.is_file() and not f.name.startswith("."):
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"folder": folder,
})
if files:
result[folder] = files
# Drafts are at case level, not under documents
drafts_path = case_dir / "drafts"
if drafts_path.exists():
files = []
for f in sorted(drafts_path.iterdir()):
if f.is_file() and not f.name.startswith("."):
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"folder": "drafts",
})
if files:
result["drafts"] = files
return result
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
async def api_read_local_file(case_number: str, folder: str, filename: str):
"""Read contents of a local case file."""
if folder not in ("research", "proofread", "drafts"):
raise HTTPException(400, "Invalid folder")
case_dir = config.find_case_dir(case_number)
if folder == "drafts":
path = case_dir / "drafts" / filename
else:
path = case_dir / "documents" / folder / filename
if not path.exists() or not path.is_file():
raise HTTPException(404, "קובץ לא נמצא")
return FileResponse(path, media_type="text/plain; charset=utf-8", filename=filename)
# ── Exports API — drafts, versions, download, upload, mark-final ── # ── Exports API — drafts, versions, download, upload, mark-final ──

View File

@@ -154,6 +154,14 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
.doc-item .doc-icon { color: #999; } .doc-item .doc-icon { color: #999; }
.doc-item .doc-name { flex: 1; } .doc-item .doc-name { flex: 1; }
.doc-item .doc-status { font-size: 0.75em; color: #999; } .doc-item .doc-status { font-size: 0.75em; color: #999; }
.doc-status.completed { color: #27ae60; font-weight: 700; font-size: 1em; }
.doc-status.processing { display: inline-block; width: 14px; height: 14px; border: 2px solid #e94560; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
.doc-status.pending { color: #ccc; }
.doc-status.failed { color: #e94560; font-weight: 700; }
.btn-retry { background: none; border: 1px solid #e94560; color: #e94560; border-radius: 4px; padding: 2px 8px; font-size: 0.75em; cursor: pointer; margin-right: 6px; }
.btn-retry:hover { background: #e94560; color: #fff; }
.processing-badge { display: inline-flex; align-items: center; gap: 4px; color: #e94560; font-size: 0.78em; font-weight: 500; }
.mini-spinner { display: inline-block; width: 10px; height: 10px; border: 1.5px solid #e94560; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
/* Upload zone (reusable) */ /* Upload zone (reusable) */
.upload-zone { .upload-zone {
@@ -217,6 +225,19 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
.toast.error { background: #e94560; } .toast.error { background: #e94560; }
.toast.success { background: #27ae60; } .toast.success { background: #27ae60; }
/* ── Local files (research, drafts, proofread) ───────── */
.local-file-group { margin-bottom: 12px; }
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
.local-file-item {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border: 1px solid #eee; border-radius: 6px; margin-bottom: 4px; background: #fafafa;
cursor: pointer; transition: background 0.15s;
}
.local-file-item:hover { background: #f0f0f0; }
.local-file-item .lf-icon { flex-shrink: 0; font-size: 1.1em; }
.local-file-item .lf-name { flex: 1; font-size: 0.85em; font-weight: 500; word-break: break-all; }
.local-file-item .lf-meta { font-size: 0.72em; color: #999; flex-shrink: 0; }
/* ── Exports / Drafts ─────────────────────────────────── */ /* ── Exports / Drafts ─────────────────────────────────── */
.export-item { .export-item {
display: flex; align-items: center; gap: 12px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; padding: 10px 14px;
@@ -448,6 +469,14 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
</div> </div>
</div> </div>
<!-- Research & Work Files -->
<div class="card">
<div class="card-header">מחקר וניתוח</div>
<div class="card-body" id="caseLocalFiles">
<div class="empty">אין קבצי מחקר</div>
</div>
</div>
<!-- Exports / Drafts --> <!-- Exports / Drafts -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -544,6 +573,12 @@ function navigate(path) {
} }
function handleRoute() { function handleRoute() {
// Clean up polling timer when navigating away
if (window._docPollTimer) {
clearInterval(window._docPollTimer);
window._docPollTimer = null;
}
const hash = window.location.hash || '#/'; const hash = window.location.hash || '#/';
const pages = document.querySelectorAll('.page'); const pages = document.querySelectorAll('.page');
pages.forEach(p => p.classList.remove('active')); pages.forEach(p => p.classList.remove('active'));
@@ -611,6 +646,7 @@ async function loadCaseList() {
<div class="case-title">${esc(c.title)}</div> <div class="case-title">${esc(c.title)}</div>
<div class="case-meta"> <div class="case-meta">
${c.document_count !== undefined ? `<span>${c.document_count} מסמכים</span>` : ''} ${c.document_count !== undefined ? `<span>${c.document_count} מסמכים</span>` : ''}
${c.processing_count > 0 ? `<span class="processing-badge"><span class="mini-spinner"></span> ${c.processing_count} בעיבוד</span>` : ''}
${c.committee_type ? `<span>${esc(c.committee_type)}</span>` : ''} ${c.committee_type ? `<span>${esc(c.committee_type)}</span>` : ''}
${c.hearing_date ? `<span>דיון: ${esc(c.hearing_date)}</span>` : ''} ${c.hearing_date ? `<span>דיון: ${esc(c.hearing_date)}</span>` : ''}
</div> </div>
@@ -834,16 +870,37 @@ async function loadCaseView(caseNumber) {
<span class="count">${groups[type].length}</span> <span class="count">${groups[type].length}</span>
</div>`; </div>`;
for (const doc of groups[type]) { for (const doc of groups[type]) {
const statusIcon = doc.extraction_status === 'completed' ? '&#10003;' : '&#9711;'; let statusHtml;
if (doc.extraction_status === 'completed') {
statusHtml = '<span class="doc-status completed">&#10003;</span>';
} else if (doc.extraction_status === 'processing') {
statusHtml = '<span class="doc-status processing"></span>';
} else if (doc.extraction_status === 'failed') {
statusHtml = `<span class="doc-status failed">&#10007;</span><button class="btn-retry" onclick="retryDoc('${esc(caseNumber)}','${esc(doc.id)}')">נסה שוב</button>`;
} else {
statusHtml = '<span class="doc-status pending">&#9711;</span>';
}
html += `<div class="doc-item"> html += `<div class="doc-item">
<span class="doc-icon">&#128196;</span> <span class="doc-icon">&#128196;</span>
<span class="doc-name">${esc(doc.title)}</span> <span class="doc-name">${esc(doc.title)}</span>
<span class="doc-status">${statusIcon}</span> ${statusHtml}
</div>`; </div>`;
} }
html += '</div>'; html += '</div>';
} }
document.getElementById('caseDocsList').innerHTML = html; document.getElementById('caseDocsList').innerHTML = html;
// Auto-refresh while documents are still processing
if (data.documents?.some(d => d.extraction_status !== 'completed')) {
if (!window._docPollTimer) {
window._docPollTimer = setInterval(() => loadCaseView(caseNumber), 5000);
}
} else {
if (window._docPollTimer) {
clearInterval(window._docPollTimer);
window._docPollTimer = null;
}
}
} }
} catch (e) { } catch (e) {
document.getElementById('caseViewTitle').textContent = `תיק ${caseNumber} לא נמצא`; document.getElementById('caseViewTitle').textContent = `תיק ${caseNumber} לא נמצא`;
@@ -858,10 +915,58 @@ async function loadCaseView(caseNumber) {
} }
setupCaseUpload(caseNumber); setupCaseUpload(caseNumber);
loadLocalFiles(caseNumber);
loadExports(caseNumber); loadExports(caseNumber);
setupExportUpload(caseNumber); setupExportUpload(caseNumber);
} }
const FOLDER_LABELS = { research: 'מחקר וניתוח', proofread: 'טקסט מוגה', drafts: 'טיוטות' };
const FOLDER_ICONS = { research: '&#128218;', proofread: '&#9989;', drafts: '&#128221;' };
async function loadLocalFiles(caseNumber) {
const container = document.getElementById('caseLocalFiles');
try {
const res = await fetch(API + '/cases/' + encodeURIComponent(caseNumber) + '/local-files');
const data = await res.json();
const folders = Object.keys(data);
if (!folders.length) {
container.innerHTML = '<div class="empty">אין קבצי מחקר או ניתוח</div>';
return;
}
let html = '';
for (const folder of ['research', 'drafts', 'proofread']) {
if (!data[folder]) continue;
html += `<div class="local-file-group">
<div class="local-file-group-header">${FOLDER_LABELS[folder] || folder} (${data[folder].length})</div>`;
for (const f of data[folder]) {
const date = new Date(f.modified_at * 1000);
const dateStr = date.toLocaleDateString('he-IL');
const url = API + '/cases/' + encodeURIComponent(caseNumber) + '/local-files/' + encodeURIComponent(folder) + '/' + encodeURIComponent(f.filename);
html += `<a href="${url}" target="_blank" style="text-decoration:none;color:inherit">
<div class="local-file-item">
<span class="lf-icon">${FOLDER_ICONS[folder] || '&#128196;'}</span>
<span class="lf-name">${esc(f.filename)}</span>
<span class="lf-meta">${dateStr} &middot; ${formatSize(f.size)}</span>
</div></a>`;
}
html += '</div>';
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="empty">שגיאה בטעינת קבצים מקומיים</div>';
}
}
async function retryDoc(caseNumber, docId) {
const btn = event.target;
btn.disabled = true;
btn.textContent = '...';
try {
await fetch(`/api/cases/${caseNumber}/documents/${docId}/reprocess`, {method: 'POST'});
} catch (e) { /* will show on next refresh */ }
loadCaseView(caseNumber);
}
async function openPaperclip(caseNumber) { async function openPaperclip(caseNumber) {
// Try to find the Paperclip project URL // Try to find the Paperclip project URL
try { try {