feat(documents): §2 카테고리 전용 페이지 + 승인 UI #2
@@ -315,6 +315,60 @@ async def list_library_documents(
|
||||
)
|
||||
|
||||
|
||||
# ─── Section 2: 카테고리 집계 (Sidebar / Dashboard) ───
|
||||
#
|
||||
# documents.category (§1 에서 추가) 가 1차 진입점. 이 엔드포인트는 Sidebar 배지 및
|
||||
# /dashboard 카테고리 카드 용. ai_suggestion.proposed_category='library' 인
|
||||
# 승인 대기 건수는 /library 의 pending 배지로 별도 표시.
|
||||
|
||||
|
||||
@router.get("/stats/category-counts")
|
||||
async def get_category_counts(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리별 문서 건수 + 승인 대기 (library 제안) 건수.
|
||||
|
||||
Response:
|
||||
{
|
||||
"counts": { "document": 640, "library": 12, "news": 311, ... },
|
||||
"library_pending_suggestions": 17
|
||||
}
|
||||
|
||||
- 전제: §1 의 documents.category enum + ai_suggestion JSONB 가 이미 적용됨
|
||||
- category IS NULL 인 문서는 counts 에서 제외 (§1 백필 전 드문 상태)
|
||||
"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
count_rows = await session.execute(
|
||||
sql_text("""
|
||||
SELECT category::text AS category, COUNT(*) AS cnt
|
||||
FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND category IS NOT NULL
|
||||
GROUP BY category
|
||||
""")
|
||||
)
|
||||
counts: dict[str, int] = {row.category: row.cnt for row in count_rows}
|
||||
|
||||
pending_scalar = (
|
||||
await session.execute(
|
||||
sql_text("""
|
||||
SELECT COUNT(*)
|
||||
FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND ai_suggestion IS NOT NULL
|
||||
AND ai_suggestion->>'proposed_category' = 'library'
|
||||
""")
|
||||
)
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"library_pending_suggestions": int(pending_scalar or 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
<script>
|
||||
// Sidebar — §2 재구성.
|
||||
//
|
||||
// 구조:
|
||||
// 1) 카테고리 내비 (Section 2 — documents.category 1차 진입점)
|
||||
// · 문서 / 자료실 / 뉴스 / 메모 / (Audio §3) / (Video §3) / 검색
|
||||
// · count 배지: GET /api/documents/stats/category-counts
|
||||
// · 자료실에는 pending suggestion 배지 별도 표시
|
||||
// 2) 도메인 트리 (기존 Phase 2 기능 — /documents 페이지 필터링)
|
||||
// 3) 스마트 그룹 + Inbox (기존)
|
||||
//
|
||||
// 제약: audio/video 는 §3 에서 채우므로 여기서는 주석 자리로 둔다.
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
Inbox,
|
||||
Clock,
|
||||
Mail,
|
||||
Scale,
|
||||
StickyNote,
|
||||
Newspaper,
|
||||
Search,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// ─── 도메인 트리 (기존) ───
|
||||
|
||||
let tree = $state([]);
|
||||
let loading = $state(true);
|
||||
let treeLoading = $state(true);
|
||||
let expanded = $state({});
|
||||
|
||||
let activeDomain = $derived($page.url.searchParams.get('domain'));
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
const DOMAIN_COLORS = {
|
||||
'Philosophy': 'var(--domain-philosophy)',
|
||||
@@ -21,13 +47,34 @@
|
||||
};
|
||||
|
||||
async function loadTree() {
|
||||
loading = true;
|
||||
treeLoading = true;
|
||||
try {
|
||||
tree = await api('/documents/tree');
|
||||
} catch (err) {
|
||||
console.error('트리 로딩 실패:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
treeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 카테고리 count (§2 신규) ───
|
||||
|
||||
let categoryCounts = $state({});
|
||||
let libraryPending = $state(0);
|
||||
let countsLoading = $state(true);
|
||||
|
||||
async function loadCategoryCounts() {
|
||||
countsLoading = true;
|
||||
try {
|
||||
const res = await api('/documents/stats/category-counts');
|
||||
categoryCounts = res?.counts ?? {};
|
||||
libraryPending = res?.library_pending_suggestions ?? 0;
|
||||
} catch {
|
||||
// §1 미적용 환경에서는 엔드포인트가 없을 수 있다 — 배지만 숨기고 내비는 유지.
|
||||
categoryCounts = {};
|
||||
libraryPending = 0;
|
||||
} finally {
|
||||
countsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +82,7 @@
|
||||
expanded[path] = !expanded[path];
|
||||
}
|
||||
|
||||
function navigate(path) {
|
||||
function navigateDomain(path) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.delete('page');
|
||||
if (path) {
|
||||
@@ -52,10 +99,10 @@
|
||||
}
|
||||
|
||||
$effect(() => { loadTree(); });
|
||||
$effect(() => { loadCategoryCounts(); });
|
||||
|
||||
$effect(() => {
|
||||
if (activeDomain) {
|
||||
// 선택된 경로의 부모들 자동 펼치기
|
||||
const parts = activeDomain.split('/');
|
||||
let path = '';
|
||||
for (const part of parts) {
|
||||
@@ -65,9 +112,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));
|
||||
// 도메인 트리 전체 count (stats 엔드포인트가 죽어있을 때 문서 배지 fallback 으로도 사용)
|
||||
let treeTotal = $derived(tree.reduce((sum, n) => sum + n.count, 0));
|
||||
let documentCount = $derived(categoryCounts.document ?? treeTotal);
|
||||
|
||||
// ArrowUp/Down 키보드 nav — 현재 펼쳐진 tree-row만 traverse
|
||||
function handleTreeKeydown(e) {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const root = e.currentTarget;
|
||||
@@ -88,31 +136,117 @@
|
||||
|
||||
<aside class="h-full flex flex-col bg-sidebar border-r border-default overflow-y-auto">
|
||||
<div class="px-4 py-3 border-b border-default">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">분류</h2>
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">카테고리</h2>
|
||||
</div>
|
||||
|
||||
<!-- 전체 문서 -->
|
||||
<div class="px-2 pt-2">
|
||||
<button
|
||||
onclick={() => navigate(null)}
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{!activeDomain ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
<!-- 카테고리 내비 (§2) -->
|
||||
<nav class="px-2 py-2 flex flex-col gap-0.5">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath === '/documents' ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
전체 문서
|
||||
문서
|
||||
</span>
|
||||
{#if totalCount > 0}
|
||||
<span class="text-xs text-dim">{totalCount}</span>
|
||||
{#if documentCount > 0}
|
||||
<span class="text-xs text-dim">{documentCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/library"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath.startsWith('/library') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FolderTree size={16} />
|
||||
자료실
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
{#if libraryPending > 0}
|
||||
<span
|
||||
class="text-[10px] font-medium bg-warning/15 text-warning border border-warning/30 rounded px-1.5 py-0.5"
|
||||
title="승인 대기 제안"
|
||||
>
|
||||
제안 {libraryPending}
|
||||
</span>
|
||||
{/if}
|
||||
{#if categoryCounts.library > 0}
|
||||
<span class="text-xs text-dim">{categoryCounts.library}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/news"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath.startsWith('/news') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Newspaper size={16} />
|
||||
뉴스
|
||||
</span>
|
||||
{#if categoryCounts.news > 0}
|
||||
<span class="text-xs text-dim">{categoryCounts.news}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/memos"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath.startsWith('/memos') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<StickyNote size={16} />
|
||||
메모
|
||||
</span>
|
||||
{#if categoryCounts.memo > 0}
|
||||
<span class="text-xs text-dim">{categoryCounts.memo}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!--
|
||||
§3 에서 채울 자리 — audio/video 네비:
|
||||
<a href="/audio">Audio · {categoryCounts.audio}</a>
|
||||
<a href="/video">Video · {categoryCounts.video}</a>
|
||||
-->
|
||||
|
||||
<a
|
||||
href="/ask"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath.startsWith('/ask') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Search size={16} />
|
||||
검색 · 질문
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 도메인 트리 (기존 /documents 필터 용) -->
|
||||
<div class="px-4 pt-3 pb-1 border-t border-default">
|
||||
<h3 class="text-[10px] font-semibold text-dim uppercase tracking-wider">도메인</h3>
|
||||
</div>
|
||||
<nav
|
||||
class="px-2 py-1 flex-shrink-0 max-h-[40vh] overflow-y-auto"
|
||||
onkeydown={handleTreeKeydown}
|
||||
>
|
||||
<button
|
||||
onclick={() => navigateDomain(null)}
|
||||
class="w-full flex items-center justify-between px-3 py-1.5 rounded-md text-sm transition-colors
|
||||
{!activeDomain && currentPath === '/documents' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
<span>전체 도메인</span>
|
||||
{#if treeTotal > 0}
|
||||
<span class="text-xs">{treeTotal}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 트리 -->
|
||||
<nav class="flex-1 px-2 py-2" onkeydown={handleTreeKeydown}>
|
||||
{#if loading}
|
||||
{#each Array(5) as _}
|
||||
<div class="h-8 bg-surface rounded-md animate-pulse mx-1 mb-1"></div>
|
||||
{#if treeLoading}
|
||||
{#each Array(3) as _}
|
||||
<div class="h-7 bg-surface rounded-md animate-pulse mx-1 mb-1"></div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each tree as node}
|
||||
@@ -140,10 +274,10 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => navigate(n.path)}
|
||||
onclick={() => navigateDomain(n.path)}
|
||||
data-tree-row
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
|
||||
class="flex-1 flex items-center justify-between px-2 py-1 rounded-md text-sm transition-colors
|
||||
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -188,30 +322,14 @@
|
||||
>
|
||||
<Mail size={14} /> 이메일
|
||||
</button>
|
||||
<a
|
||||
href="/library"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname === '/library' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
<FolderTree size={14} /> 자료실
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 메모 & Inbox -->
|
||||
<!-- Inbox -->
|
||||
<div class="px-2 py-2 border-t border-default">
|
||||
<a
|
||||
href="/memos"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname === '/memos' ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<StickyNote size={16} />
|
||||
메모
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/inbox"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{currentPath.startsWith('/inbox') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Inbox size={16} />
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
// 자동 분류 제안 승인 UI (Section 2)
|
||||
//
|
||||
// 역할: documents.ai_suggestion 에 축적된 제안을 카드 리스트로 노출하고
|
||||
// 사용자가 승인/반려 할 수 있게 한다. 자료실 /library 페이지 상단에 렌더.
|
||||
//
|
||||
// API 계약 (§1 이 제공):
|
||||
// GET /api/documents?has_suggestion=true&proposed_category=<cat>
|
||||
// POST /api/documents/{id}/accept-suggestion body: { expected_source_updated_at }
|
||||
// DELETE /api/documents/{id}/suggestion
|
||||
//
|
||||
// 409 Conflict: 다른 탭에서 문서가 수정되었거나 제안 payload 가 새 classify 결과로
|
||||
// 덮인 경우. 토스트 + 해당 카드 refresh (성공적인 카드는 사라짐).
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Check, X, Loader2, RefreshCcw, FileText } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Suggestion {
|
||||
proposed_category: string;
|
||||
proposed_path: string | null;
|
||||
proposed_doctype: string | null;
|
||||
confidence: number | null;
|
||||
source_updated_at: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface SuggestionDoc {
|
||||
id: number;
|
||||
title: string | null;
|
||||
category: string | null;
|
||||
file_format?: string | null;
|
||||
ai_suggestion: Suggestion;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// 대기 카테고리 (현재는 'library' 만 사용)
|
||||
proposedCategory?: string;
|
||||
// 부모가 승인/반려 후 갱신하고 싶을 때 호출되는 콜백 (카운트 배지 갱신 용)
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
let { proposedCategory = 'library', onChange }: Props = $props();
|
||||
|
||||
let docs = $state<SuggestionDoc[]>([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
let bulkRunning = $state(false);
|
||||
// 행 단위 작업 상태 (id → 'accept' | 'reject')
|
||||
let rowBusy = $state<Record<number, 'accept' | 'reject' | null>>({});
|
||||
let selected = $state<Set<number>>(new Set());
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
has_suggestion: 'true',
|
||||
proposed_category: proposedCategory,
|
||||
page: '1',
|
||||
page_size: '50',
|
||||
});
|
||||
const res = await api<{ items: SuggestionDoc[]; total: number }>(
|
||||
`/documents?${params}`,
|
||||
);
|
||||
docs = res.items ?? [];
|
||||
total = res.total ?? docs.length;
|
||||
selected = new Set();
|
||||
} catch (err) {
|
||||
// 엔드포인트 미도착(=§1 미머지) 상태에서도 /library 페이지 자체는 살아 있어야 하므로
|
||||
// 실패는 조용히 빈 상태로 전환한다.
|
||||
docs = [];
|
||||
total = 0;
|
||||
// 404/422 외 에러는 사용자에게 알린다.
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status && status !== 404 && status !== 422) {
|
||||
addToast('error', '제안 목록 로딩 실패');
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function accept(doc: SuggestionDoc) {
|
||||
rowBusy[doc.id] = 'accept';
|
||||
try {
|
||||
await api(`/documents/${doc.id}/accept-suggestion`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expected_source_updated_at: doc.ai_suggestion.source_updated_at,
|
||||
}),
|
||||
});
|
||||
addToast('success', `${doc.title ?? '제목 없음'} → 자료실 이동`);
|
||||
// 해당 카드 제거 (서버가 200 + ai_suggestion=NULL)
|
||||
docs = docs.filter((d) => d.id !== doc.id);
|
||||
total = Math.max(0, total - 1);
|
||||
selected.delete(doc.id);
|
||||
selected = new Set(selected);
|
||||
onChange?.();
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status === 409) {
|
||||
addToast(
|
||||
'warning',
|
||||
'문서가 다른 곳에서 수정돼 제안이 만료되었습니다. 목록을 새로고침합니다.',
|
||||
);
|
||||
await load();
|
||||
} else {
|
||||
addToast('error', '승인 실패');
|
||||
}
|
||||
} finally {
|
||||
rowBusy[doc.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(doc: SuggestionDoc) {
|
||||
rowBusy[doc.id] = 'reject';
|
||||
try {
|
||||
await api(`/documents/${doc.id}/suggestion`, { method: 'DELETE' });
|
||||
addToast('info', '제안 반려');
|
||||
docs = docs.filter((d) => d.id !== doc.id);
|
||||
total = Math.max(0, total - 1);
|
||||
selected.delete(doc.id);
|
||||
selected = new Set(selected);
|
||||
onChange?.();
|
||||
} catch (err) {
|
||||
addToast('error', '반려 실패');
|
||||
} finally {
|
||||
rowBusy[doc.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSelected() {
|
||||
if (selected.size === 0 || bulkRunning) return;
|
||||
bulkRunning = true;
|
||||
const targets = docs.filter((d) => selected.has(d.id));
|
||||
let ok = 0;
|
||||
let staleOrFail = 0;
|
||||
for (const doc of targets) {
|
||||
try {
|
||||
await api(`/documents/${doc.id}/accept-suggestion`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expected_source_updated_at: doc.ai_suggestion.source_updated_at,
|
||||
}),
|
||||
});
|
||||
ok += 1;
|
||||
} catch {
|
||||
staleOrFail += 1;
|
||||
}
|
||||
}
|
||||
bulkRunning = false;
|
||||
if (ok > 0) {
|
||||
addToast('success', `${ok}건 자료실로 이동`);
|
||||
}
|
||||
if (staleOrFail > 0) {
|
||||
addToast('warning', `${staleOrFail}건은 실패/만료 — 목록 새로고침`);
|
||||
}
|
||||
onChange?.();
|
||||
await load();
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === docs.length) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(docs.map((d) => d.id));
|
||||
}
|
||||
}
|
||||
|
||||
function formatConfidence(c: number | null | undefined): string {
|
||||
if (c === null || c === undefined) return '—';
|
||||
return `${Math.round(c * 100)}%`;
|
||||
}
|
||||
|
||||
function proposedPathLabel(doc: SuggestionDoc): string {
|
||||
const s = doc.ai_suggestion;
|
||||
if (s.proposed_path) return s.proposed_path;
|
||||
if (s.proposed_doctype) return `@${s.proposed_category}/${s.proposed_doctype}`;
|
||||
return `@${s.proposed_category}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="border border-default rounded-lg bg-surface-muted/30 overflow-hidden"
|
||||
aria-label="자동 분류 제안 승인"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 px-4 py-3 border-b border-default bg-surface">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText size={16} class="text-dim" />
|
||||
<h2 class="text-sm font-semibold text-text">자동 분류 제안</h2>
|
||||
{#if !loading}
|
||||
<span
|
||||
class="text-xs text-dim bg-surface-muted px-1.5 py-0.5 rounded"
|
||||
data-testid="suggestion-total"
|
||||
>
|
||||
{total}건
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if docs.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleAll}
|
||||
class="text-xs text-dim hover:text-text"
|
||||
>
|
||||
{selected.size === docs.length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.size > 0}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
loading={bulkRunning}
|
||||
onclick={acceptSelected}
|
||||
icon={Check}
|
||||
>
|
||||
선택 {selected.size}건 승인
|
||||
</Button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-dim hover:text-text rounded hover:bg-surface-hover"
|
||||
onclick={load}
|
||||
aria-label="새로고침"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCcw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="px-4 py-6 flex items-center gap-2 text-dim text-sm">
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
제안 불러오는 중…
|
||||
</div>
|
||||
{:else if docs.length === 0}
|
||||
<div class="px-4 py-5 text-sm text-dim">
|
||||
대기 중인 제안이 없습니다. 발주서·세금계산서 등이 분류되면 여기 쌓입니다.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-default/40">
|
||||
{#each docs as doc (doc.id)}
|
||||
{@const busy = rowBusy[doc.id]}
|
||||
{@const isSel = selected.has(doc.id)}
|
||||
<li class="flex items-start gap-3 px-4 py-3 hover:bg-surface/60">
|
||||
<label class="mt-0.5 flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSel}
|
||||
onchange={() => toggleSelect(doc.id)}
|
||||
class="h-4 w-4 rounded border-default"
|
||||
aria-label="선택"
|
||||
/>
|
||||
</label>
|
||||
<a
|
||||
href={`/documents/${doc.id}`}
|
||||
class="flex-1 min-w-0 space-y-1"
|
||||
>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-medium text-text truncate">
|
||||
{doc.title ?? `문서 ${doc.id}`}
|
||||
</span>
|
||||
{#if doc.category}
|
||||
<span class="text-[10px] uppercase tracking-wide text-dim">
|
||||
현재: {doc.category}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-dim">
|
||||
<span class="font-mono text-accent">{proposedPathLabel(doc)}</span>
|
||||
{#if doc.ai_suggestion.proposed_doctype}
|
||||
<span class="px-1.5 py-0.5 rounded bg-surface-muted">
|
||||
{doc.ai_suggestion.proposed_doctype}
|
||||
</span>
|
||||
{/if}
|
||||
<span>신뢰도 {formatConfidence(doc.ai_suggestion.confidence)}</span>
|
||||
{#if doc.ai_suggestion.reason}
|
||||
<span class="truncate">· {doc.ai_suggestion.reason}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={X}
|
||||
loading={busy === 'reject'}
|
||||
disabled={busy === 'accept'}
|
||||
onclick={() => reject(doc)}
|
||||
>
|
||||
반려
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon={Check}
|
||||
loading={busy === 'accept'}
|
||||
disabled={busy === 'reject'}
|
||||
onclick={() => accept(doc)}
|
||||
>
|
||||
승인
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -27,6 +27,15 @@
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import SuggestionReview from '$lib/components/SuggestionReview.svelte';
|
||||
|
||||
// §2: /library 는 "자료실 + 승인 대기함" 2역할을 겸한다.
|
||||
// 승인/반려 후 자료실 목록·트리·facet count 를 재조회.
|
||||
function handleSuggestionChange() {
|
||||
loadTree();
|
||||
loadDocs();
|
||||
loadFacetCounts();
|
||||
}
|
||||
|
||||
// ─── 상태 ───
|
||||
|
||||
@@ -445,6 +454,14 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기함 (§2) — ai_suggestion.proposed_category='library' 문서 -->
|
||||
<div class="max-w-7xl mx-auto mb-4">
|
||||
<SuggestionReview
|
||||
proposedCategory="library"
|
||||
onChange={handleSuggestionChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<!-- 왼쪽: 트리 (5/12) -->
|
||||
<aside class="lg:col-span-5 xl:col-span-4">
|
||||
|
||||
Reference in New Issue
Block a user