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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user