feat(dashboard): rework paperless review actions

This commit is contained in:
Fizzlepoof
2026-05-14 19:43:57 +00:00
parent c0b3a52074
commit 5bb519f895
3 changed files with 23 additions and 22 deletions

View File

@@ -76,14 +76,15 @@ class Handler(SimpleHTTPRequestHandler):
def _paperless_action(self, payload: dict): def _paperless_action(self, payload: dict):
item_id = str(payload.get('id', '')).strip() item_id = str(payload.get('id', '')).strip()
action = str(payload.get('action', '')).strip().lower() action = str(payload.get('action', '')).strip().lower()
if not item_id or action not in {'acknowledge', 'dismiss'}: if not item_id or action not in {'needs_attention', 'good_to_go'}:
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'}) return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
state = load_json(PAPERLESS_STATE, {}) state = load_json(PAPERLESS_STATE, {})
current = state.get(item_id, {}) if isinstance(state, dict) else {} current = state.get(item_id, {}) if isinstance(state, dict) else {}
if action == 'acknowledge': current_status = str(current.get('status') or '').lower()
new_status = 'active' if current.get('status') == 'acknowledged' else 'acknowledged' if action == 'needs_attention':
new_status = 'active' if current_status == 'needs_attention' else 'needs_attention'
else: else:
new_status = 'dismissed' new_status = 'good_to_go'
state[item_id] = {'status': new_status} state[item_id] = {'status': new_status}
save_json(PAPERLESS_STATE, state) save_json(PAPERLESS_STATE, state)
return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status}) return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status})

View File

@@ -333,13 +333,15 @@ def paperless_review_section()->str:
state=load_paperless_state() state=load_paperless_state()
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel') if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'} urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
active=[]; acknowledged=[] active=[]; needs_attention=[]
for item in items: for item in items:
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower() status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
if status=='dismissed': if status in {'dismissed','good_to_go'}:
continue continue
(acknowledged if status=='acknowledged' else active).append(item) if status=='acknowledged':
def render_card(item:dict[str,Any], acknowledged_card:bool=False)->str: status='needs_attention'
(needs_attention if status=='needs_attention' else active).append(item)
def render_card(item:dict[str,Any], needs_attention_card:bool=False)->str:
triage=item.get('triage',{}) or {} triage=item.get('triage',{}) or {}
current=item.get('current',{}) or {} current=item.get('current',{}) or {}
urgency=triage.get('urgency','none') urgency=triage.get('urgency','none')
@@ -347,9 +349,11 @@ def paperless_review_section()->str:
reason=strip_markup(triage.get('reason','')) reason=strip_markup(triage.get('reason',''))
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x] meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
meta=''.join(str(x) for x in meta_parts) meta=''.join(str(x) for x in meta_parts)
status_class=' acknowledged' if acknowledged_card else '' status_class=' needs-attention' if needs_attention_card else ''
primary_label='Clear flag' if needs_attention_card else 'Needs attention'
primary_action='needs_attention'
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'> return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div> <div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='{primary_action}'>{primary_label}</button><button type='button' class='paperless-btn good' data-paperless-action='good_to_go'>Good to go</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4> <h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''} {f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''} {f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
@@ -358,17 +362,17 @@ def paperless_review_section()->str:
visible_active=active[:3] visible_active=active[:3]
overflow_active=active[3:] overflow_active=active[3:]
active_cards=''.join(render_card(item,False) for item in visible_active) active_cards=''.join(render_card(item,False) for item in visible_active)
acked_cards=''.join(render_card(item,True) for item in acknowledged[:8]) flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
count=len(active) count=len(active)
ack_count=len(acknowledged) flag_count=len(needs_attention)
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>" body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
if overflow_active: if overflow_active:
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>" body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
if ack_count: if flag_count:
body += f"<details class='paperless-acked'><summary>Acknowledged <span class='badge'>{esc(str(ack_count))}</span></summary><div class='paperless-grid acked-grid'>{acked_cards}</div></details>" body += f"<details class='paperless-acked' open><summary>Needs attention <span class='badge'>{esc(str(flag_count))}</span></summary><div class='paperless-grid acked-grid'>{flagged_cards}</div><p class='empty'>Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.</p></details>"
if count+ack_count>3: if count+flag_count>3:
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>" body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{ack_count} acknowledged · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=count>0,extra_class='panel') return collapsible_section(f'📄 Paperless Review ({count} active)',f'{flag_count} flagged for follow-up · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel')
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str: def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
if not items: if not items:
@@ -499,11 +503,7 @@ def feedback_bootstrap()->str:
const action=btn.dataset.paperlessAction; const action=btn.dataset.paperlessAction;
btn.disabled=true; btn.disabled=true;
postJson('/api/paperless-action',{id,action}).then(()=>{ postJson('/api/paperless-action',{id,action}).then(()=>{
if(action==='dismiss'){ window.location.reload();
card.remove();
} else {
card.classList.toggle('acknowledged');
}
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;}); }).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
}); });
const reset=document.querySelector('[data-reset-topic-prefs]'); const reset=document.querySelector('[data-reset-topic-prefs]');

View File

@@ -10,4 +10,4 @@
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem} .forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
/* Paperless review queue */ /* Paperless review queue */
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.acknowledged{opacity:.7}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.danger{border-color:rgba(255,107,107,.4);color:#ffd9d9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}} .badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),0 10px 24px rgba(0,0,0,.22)}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}}