feat(ui): Phase F — Inbox 분류 UX + review_status hotfix

F.1 review_status 버그 fix + 승인 UX 가드:
- PATCH body에 review_status: 'approved' 누락 버그 수정 (hotfix)
  → 기존에는 승인해도 문서가 inbox에서 사라지지 않던 증상 해결
- isApprovable(doc): effective domain(override or original)이 비어 있으면 false
- 미분류 행: 체크박스 disabled + ⚠ "도메인 선택 필요" Badge 인라인 표시
  + 카드 border-warning 강조. 클릭 자체가 막힘 (toast 경고 아님)

F.2 runes 마이그레이션 + 프리미티브 전환:
- let → $state/$derived/$derived.by, onMount 유지
- Card/Button/Select/TextInput/Badge/EmptyState/Skeleton/Modal/
  ConfirmDialog/FormatIcon/TagPill 프리미티브로 전면 재작성
- 기존 bg-[var(--*)] 클러스터 전부 제거

F.3 필터 row:
- source / format Select 드롭다운 (현재 documents에서 동적 집계)
- confidence는 백엔드 ai_confidence 필드 추가 대기 — 주석 TODO(backend)

F.4 처리 단계 가시성:
- extracted_at / ai_processed_at / embedded_at 3개 Badge
  (success tone = 완료, neutral = 대기) + source_channel 표시
- backend 전용 endpoint 없이 기존 응답 필드만으로 stop-gap

F.5 행별 override:
- Map<id, { domain?, tags? }> 로컬 state
- 도메인 select 변경 시 overrides에 기록, 원복 버튼으로 clear
- 승인(approveOne) 시점에 override를 PATCH body에 병합
- 도메인 override로 미분류 → 분류 전환 가능 (바로 승인 가능해짐)

F.6 배치 override + 재시도 stub:
- 선택 toolbar: 일괄 도메인 / 일괄 태그 modal
- 배치 override는 로컬 Map만 갱신, 실제 PATCH는 승인 시 1회
- 재시도 버튼: disabled stub (TODO backend POST /queue/retry)
- 선택 상한 50건, pLimit(5) + Promise.allSettled 일괄 승인

검증:
- npm run build 통과 (a11y 경고 fix: label → span + aria-label)
- npm run lint:tokens 229 → 204 (inbox 레거시 var() 토큰 전부 제거, -25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:46:03 +09:00
parent d83842ccd8
commit 7a38c95f3f

View File

@@ -1,20 +1,67 @@
<script>
// Phase F — Inbox 분류 UX 전면 재작성.
// - F.1: review_status: 'approved' PATCH body 누락 버그 수정 + 미분류 가드
// - F.2: runes mode + ui/* 프리미티브
// - F.3: source/format 필터 row (confidence는 백엔드 대기)
// - F.4: 처리 단계 타임스탬프 Badge 3개 (stop-gap)
// - F.5: 행별 도메인 override + tag 인라인 (로컬 state, 승인 시 반영)
// - F.6: 배치 override modal + (재시도 stub는 queue retry 백엔드 대기)
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ui } from '$lib/stores/uiState.svelte';
import { pLimit } from '$lib/utils/pLimit';
import { AlertTriangle, CheckCircle2, RotateCcw, Inbox as InboxIcon } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import FormatIcon from '$lib/components/FormatIcon.svelte';
import TagPill from '$lib/components/TagPill.svelte';
let documents = [];
let loading = true;
let selected = new Set();
const DOMAIN_OPTIONS = [
{ value: 'Knowledge/Philosophy', label: 'Knowledge / Philosophy' },
{ value: 'Knowledge/Language', label: 'Knowledge / Language' },
{ value: 'Knowledge/Engineering', label: 'Knowledge / Engineering' },
{ value: 'Knowledge/Industrial_Safety', label: 'Knowledge / Industrial Safety' },
{ value: 'Knowledge/Programming', label: 'Knowledge / Programming' },
{ value: 'Knowledge/General', label: 'Knowledge / General' },
{ value: 'Reference', label: 'Reference' },
];
const MAX_SELECTION = 50;
let documents = $state([]);
let loading = $state(true);
let busy = $state(false);
// 선택
let selected = $state(new Set());
// F.3 필터
let filterSource = $state('');
let filterFormat = $state('');
// F.5 행별 override Map<id, { domain?, tags? }>
let overrides = $state(new Map());
// F.6 batch override 입력
let batchDomainValue = $state('');
let batchTagValue = $state('');
onMount(loadInbox);
async function loadInbox() {
loading = true;
try {
// Inbox 파일만 필터
const data = await api('/documents/?page_size=100');
documents = data.items.filter(d => d.review_status === 'pending');
// TODO(backend): /documents/?review_status=pending 서버 필터 지원 시 page_size 축소
const data = await api('/documents/?page_size=200');
documents = (data.items || []).filter((d) => d.review_status === 'pending');
} catch (err) {
addToast('error', 'Inbox 로딩 실패');
} finally {
@@ -22,169 +69,424 @@
}
}
// Derived
let filteredDocs = $derived(
documents.filter((d) => {
if (filterSource && d.source_channel !== filterSource) return false;
if (filterFormat && d.file_format !== filterFormat) return false;
return true;
})
);
let sourceOptions = $derived.by(() => {
const set = new Set(documents.map((d) => d.source_channel).filter(Boolean));
return [
{ value: '', label: '모든 소스' },
...[...set].map((s) => ({ value: s, label: s })),
];
});
let formatOptions = $derived.by(() => {
const set = new Set(documents.map((d) => d.file_format).filter(Boolean));
return [
{ value: '', label: '모든 형식' },
...[...set].map((f) => ({ value: f, label: f.toUpperCase() })),
];
});
// Helper: override 고려한 effective domain/tags
function effectiveDomain(doc) {
const ov = overrides.get(doc.id);
if (ov && ov.domain !== undefined) return ov.domain;
return doc.ai_domain ?? '';
}
function effectiveTags(doc) {
const ov = overrides.get(doc.id);
if (ov && ov.tags !== undefined) return ov.tags;
return doc.ai_tags ?? [];
}
function isApprovable(doc) {
return !!effectiveDomain(doc);
}
// F.5 override setter
function setDomainOverride(id, value) {
const next = new Map(overrides);
const existing = next.get(id) ?? {};
next.set(id, { ...existing, domain: value });
overrides = next;
}
function clearOverride(id) {
const next = new Map(overrides);
next.delete(id);
overrides = next;
// 미분류로 돌아가면 선택에서도 제거
const doc = documents.find((d) => d.id === id);
if (doc && !isApprovable(doc) && selected.has(id)) {
const nextSel = new Set(selected);
nextSel.delete(id);
selected = nextSel;
}
}
// Selection
function toggleSelect(id) {
if (selected.has(id)) selected.delete(id);
else selected.add(id);
selected = selected; // 반응성 트리거
const doc = documents.find((d) => d.id === id);
if (!doc || !isApprovable(doc)) return;
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
}
function toggleAll() {
if (selected.size === documents.length) {
const approvable = filteredDocs.filter(isApprovable);
const allSelected = approvable.length > 0 && approvable.every((d) => selected.has(d.id));
if (allSelected) {
selected = new Set();
} else {
selected = new Set(documents.map(d => d.id));
selected = new Set(approvable.map((d) => d.id));
}
}
let approving = false;
let showConfirm = false;
function startApprove() {
if (selected.size === 0) {
addToast('warning', '선택된 문서가 없습니다');
return;
}
showConfirm = true;
}
let selectionCount = $derived(selected.size);
let selectionOverLimit = $derived(selectionCount > MAX_SELECTION);
let approvableCount = $derived(filteredDocs.filter(isApprovable).length);
let unclassifiedCount = $derived(documents.filter((d) => !isApprovable(d)).length);
// F.1 fix + 일괄 승인 (pLimit 5)
async function confirmApprove() {
showConfirm = false;
approving = true;
let success = 0;
const ids = [...selected];
for (const id of ids) {
try {
// AI 분류 결과 그대로 승인 (Inbox에서 이동은 classify_worker가 처리)
const doc = documents.find(d => d.id === id);
if (doc?.ai_domain) {
await api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ source_channel: 'inbox_route' }),
});
success++;
}
} catch { /* skip */ }
}
addToast('success', `${success}건 승인 완료`);
if (ids.length === 0 || ids.length > MAX_SELECTION) return;
busy = true;
const limit = pLimit(5);
const results = await Promise.allSettled(
ids.map((id) => limit(() => approveOne(id)))
);
busy = false;
const success = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.length - success;
if (success > 0) addToast('success', `${success}건 승인 완료`);
if (failed > 0) addToast('error', `${failed}건 실패`);
selected = new Set();
approving = false;
overrides = new Map();
loadInbox();
}
async function updateDomain(id, domain) {
try {
await api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_domain: domain }),
});
documents = documents.map(d => d.id === id ? { ...d, ai_domain: domain } : d);
addToast('success', '도메인 변경됨');
} catch {
addToast('error', '변경 실패');
}
async function approveOne(id) {
const doc = documents.find((d) => d.id === id);
if (!doc) return;
const ov = overrides.get(id) ?? {};
const body = {
review_status: 'approved', // F.1: 누락 버그 수정 (핵심)
ai_domain: ov.domain ?? doc.ai_domain,
};
if (ov.tags !== undefined) body.ai_tags = ov.tags;
return api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
const DOMAINS = [
'Knowledge/Philosophy',
'Knowledge/Language',
'Knowledge/Engineering',
'Knowledge/Industrial_Safety',
'Knowledge/Programming',
'Knowledge/General',
'Reference',
];
// F.6 배치 override — 로컬 상태만 변경, 실제 PATCH는 승인 시점에
function applyBatchDomain() {
if (!batchDomainValue) return;
const next = new Map(overrides);
for (const id of selected) {
const existing = next.get(id) ?? {};
next.set(id, { ...existing, domain: batchDomainValue });
}
overrides = next;
addToast('success', `${selected.size}건에 도메인 override`);
batchDomainValue = '';
ui.closeModal('batch-override-domain');
}
function applyBatchTag() {
const tag = batchTagValue.trim();
if (!tag) return;
const next = new Map(overrides);
for (const id of selected) {
const doc = documents.find((d) => d.id === id);
const existing = next.get(id) ?? {};
const current = existing.tags ?? doc?.ai_tags ?? [];
if (!current.includes(tag)) {
next.set(id, { ...existing, tags: [...current, tag] });
}
}
overrides = next;
addToast('success', `${selected.size}건에 태그 추가`);
batchTagValue = '';
ui.closeModal('batch-override-tag');
}
// F.6 재시도 stub — 백엔드 POST /queue/retry 대기
function retryStub() {
addToast('warning', '재시도 엔드포인트 구현 대기 중 (TODO backend)');
}
</script>
<div class="p-4 lg:p-6">
<div class="p-4 lg:p-6 max-w-5xl mx-auto">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Inbox</h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-[var(--warning)] text-black">{documents.length}</span>
<h2 class="text-lg font-semibold text-text">Inbox</h2>
<Badge tone="warning" size="sm">{documents.length}</Badge>
{#if unclassifiedCount > 0}
<Badge tone="warning" size="sm">
<AlertTriangle size={10} />
미분류 {unclassifiedCount}
</Badge>
{/if}
</div>
<div class="flex items-center gap-2">
<button onclick={toggleAll} class="px-3 py-1.5 text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg">
{selected.size === documents.length ? '전체 해제' : '전체 선택'}
</button>
<button
onclick={startApprove}
disabled={approving || selected.size === 0}
class="px-4 py-1.5 text-xs bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
<Button
variant="ghost"
size="sm"
onclick={toggleAll}
disabled={approvableCount === 0}
>
{approving ? '처리 중...' : `선택 승인 (${selected.size})`}
</button>
{selected.size === approvableCount && approvableCount > 0 ? '전체 해제' : '전체 선택'}
</Button>
<Button
variant="secondary"
size="sm"
icon={RotateCcw}
onclick={retryStub}
disabled
title="백엔드 구현 대기"
>
재시도
</Button>
<Button
variant="primary"
size="sm"
onclick={() => ui.openModal('approve-confirm')}
disabled={busy || selectionCount === 0 || selectionOverLimit}
loading={busy}
>
선택 승인 ({selectionCount})
</Button>
</div>
</div>
<div class="max-w-6xl mx-auto p-6">
{#if loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="bg-[var(--surface)] rounded-lg p-4 border border-[var(--border)] animate-pulse h-24"></div>
{/each}
</div>
{:else if documents.length === 0}
<div class="text-center py-20 text-[var(--text-dim)]">
<p class="text-lg">Inbox가 비어 있습니다</p>
<p class="text-sm mt-1">새 파일이 들어오면 자동으로 표시됩니다</p>
</div>
{:else}
<div class="space-y-3">
{#each documents as doc}
<div class="flex items-start gap-3 p-4 bg-[var(--surface)] border border-[var(--border)] rounded-lg" class:border-[var(--accent)]={selected.has(doc.id)}>
<!-- F.3 필터 row -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="w-44">
<Select bind:value={filterSource} options={sourceOptions} />
</div>
<div class="w-36">
<Select bind:value={filterFormat} options={formatOptions} />
</div>
<!-- confidence: TODO(backend) — ai_confidence 필드 추가 시 복원 -->
</div>
<!-- F.6 선택 toolbar (batch override) -->
{#if selectionCount > 0}
<div
class="sticky top-0 z-dropdown flex flex-wrap items-center gap-2 px-3 py-2 mb-4 rounded-md bg-accent/10 border border-accent/30 backdrop-blur-sm"
>
<span class="text-xs font-medium {selectionOverLimit ? 'text-error' : 'text-accent'}">
{selectionCount}건 선택
{#if selectionOverLimit}
<span class="ml-1">(최대 {MAX_SELECTION}건 초과)</span>
{/if}
</span>
<div class="flex-1"></div>
<Button
variant="secondary"
size="sm"
onclick={() => ui.openModal('batch-override-domain')}
disabled={selectionOverLimit}
>
일괄 도메인
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => ui.openModal('batch-override-tag')}
disabled={selectionOverLimit}
>
일괄 태그
</Button>
</div>
{/if}
<!-- 목록 -->
{#if loading}
<div class="space-y-3">
{#each Array(3) as _}
<Skeleton h="h-28" rounded="card" />
{/each}
</div>
{:else if filteredDocs.length === 0}
<EmptyState
icon={InboxIcon}
title={documents.length === 0 ? 'Inbox가 비어 있습니다' : '필터 결과 없음'}
description={documents.length === 0
? '새 파일이 들어오면 자동으로 표시됩니다.'
: '필터를 조정해 보세요.'}
/>
{:else}
<div class="space-y-3">
{#each filteredDocs as doc (doc.id)}
{@const approvable = isApprovable(doc)}
{@const isChecked = selected.has(doc.id)}
{@const ov = overrides.get(doc.id) ?? {}}
{@const displayDomain = effectiveDomain(doc)}
{@const displayTags = effectiveTags(doc)}
{@const hasOverride = ov.domain !== undefined || ov.tags !== undefined}
<Card class={approvable ? '' : 'border-warning/50'}>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={selected.has(doc.id)}
checked={isChecked}
disabled={!approvable}
onchange={() => toggleSelect(doc.id)}
class="mt-1 accent-[var(--accent)]"
class="mt-1 h-4 w-4 accent-accent disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="{doc.title || '문서'} 선택"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
<a href="/documents/{doc.id}" class="text-sm font-medium hover:text-[var(--accent)] truncate">{doc.title || '제목 없음'}</a>
<!-- 제목 + 미분류 경고 -->
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="text-faint"><FormatIcon format={doc.file_format} size={14} /></span>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-text hover:text-accent truncate"
>
{doc.title || '제목 없음'}
</a>
{#if !approvable}
<Badge tone="warning" size="sm">
<AlertTriangle size={10} />
도메인 선택 필요
</Badge>
{/if}
{#if hasOverride}
<Badge tone="accent" size="sm">override</Badge>
{/if}
</div>
{#if doc.ai_summary}
<p class="text-xs text-[var(--text-dim)] truncate">{doc.ai_summary.slice(0, 120)}</p>
<p class="text-xs text-dim truncate mb-2">{doc.ai_summary.slice(0, 140)}</p>
{/if}
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-[var(--text-dim)]">AI 분류:</span>
<!-- F.4 처리 단계 타임스탬프 -->
<div class="flex items-center gap-1.5 mb-2 flex-wrap">
<Badge tone={doc.extracted_at ? 'success' : 'neutral'} size="sm">
{#if doc.extracted_at}<CheckCircle2 size={10} />{/if}
추출
</Badge>
<Badge tone={doc.ai_processed_at ? 'success' : 'neutral'} size="sm">
{#if doc.ai_processed_at}<CheckCircle2 size={10} />{/if}
분류
</Badge>
<Badge tone={doc.embedded_at ? 'success' : 'neutral'} size="sm">
{#if doc.embedded_at}<CheckCircle2 size={10} />{/if}
임베딩
</Badge>
<span class="text-[10px] text-faint">
· {doc.source_channel || '-'}
</span>
</div>
<!-- F.5 행별 override -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-dim shrink-0">도메인</span>
<select
value={doc.ai_domain || ''}
onchange={(e) => updateDomain(doc.id, e.target.value)}
class="text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--text)]"
aria-label="도메인 선택"
value={displayDomain}
onchange={(e) => setDomainOverride(doc.id, e.currentTarget.value)}
class="text-xs px-2 py-1 rounded bg-bg border border-default text-text focus:border-accent outline-none"
>
<option value="">미분류</option>
{#each DOMAINS as d}
<option value={d}>{d.replace('Knowledge/', '')}</option>
{#each DOMAIN_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if doc.ai_tags?.length > 0}
<div class="flex gap-1 ml-2">
{#each doc.ai_tags.slice(0, 3) as tag}
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg)] rounded text-[var(--accent)]">{tag}</span>
{#if hasOverride}
<button
type="button"
onclick={() => clearOverride(doc.id)}
class="inline-flex items-center gap-1 text-[10px] text-dim hover:text-accent"
title="override 초기화"
>
<RotateCcw size={10} />
원복
</button>
{/if}
{#if displayTags.length > 0}
<div class="flex gap-1 ml-2 flex-wrap">
{#each displayTags.slice(0, 6) as tag}
<TagPill {tag} clickable={false} />
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- 확인 다이얼로그 -->
{#if showConfirm}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold mb-2">{selected.size}건을 승인합니다</h3>
<p class="text-sm text-[var(--text-dim)] mb-4">AI 분류 결과를 확정하고 Inbox에서 이동합니다.</p>
<div class="flex gap-2 justify-end">
<button onclick={() => showConfirm = false} class="px-4 py-2 text-sm bg-[var(--bg)] border border-[var(--border)] rounded-lg">취소</button>
<button onclick={confirmApprove} class="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded-lg">승인</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}
</div>
<!-- 승인 확인 -->
<ConfirmDialog
id="approve-confirm"
title="{selectionCount}건 승인"
message="선택한 문서를 승인합니다. 도메인/태그 override가 있으면 함께 저장됩니다."
confirmLabel="승인"
tone="primary"
loading={busy}
onconfirm={confirmApprove}
/>
<!-- 배치 도메인 override -->
<Modal id="batch-override-domain" title="일괄 도메인 override" size="sm">
<p class="text-xs text-dim mb-3">
선택한 {selectionCount}건에 로컬 override 적용. 아직 저장되지 않음 — 승인 시 반영됨.
</p>
<Select
bind:value={batchDomainValue}
options={DOMAIN_OPTIONS}
placeholder="도메인 선택"
label="도메인"
/>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('batch-override-domain')}>
취소
</Button>
<Button
variant="primary"
size="sm"
disabled={!batchDomainValue}
onclick={applyBatchDomain}
>
적용
</Button>
{/snippet}
</Modal>
<!-- 배치 태그 override -->
<Modal id="batch-override-tag" title="일괄 태그 추가" size="sm">
<p class="text-xs text-dim mb-3">
선택한 {selectionCount}건에 태그 override. 이미 같은 태그는 skip.
</p>
<TextInput bind:value={batchTagValue} placeholder="태그 입력" label="태그" />
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('batch-override-tag')}>
취소
</Button>
<Button
variant="primary"
size="sm"
disabled={!batchTagValue.trim()}
onclick={applyBatchTag}
>
적용
</Button>
{/snippet}
</Modal>