feat(ui): Phase D.3 — multi-select + batch actions (pLimit)

- DocumentTable/DocumentCard: selectable/selectedIds/onselectionchange props
  * Table: 왼쪽 6px 너비 체크박스 컬럼
  * Card: 좌상단 absolute 체크박스 (hover 또는 selected 시 표시)
  * 체크박스 onclick stopPropagation으로 행 select와 분리
- documents/+page.svelte:
  * selectedIds = $state(new Set()), URL/필터 변경 시 자동 초기화
  * sticky 선택 toolbar (selection > 0): N건 / 전체 선택 / 선택 해제 /
    일괄 도메인 / 일괄 태그 / 일괄 삭제
  * 50건 상한 UI 가드 (초과 시 경고 + 모든 bulk 버튼 disabled)
  * Bulk modals:
    - 일괄 도메인: Select (Knowledge/* 6종 + Reference)
    - 일괄 태그: TextInput (기존 ai_tags에 추가, 중복 skip)
    - 일괄 삭제: ConfirmDialog (delete_file=true)
  * runBulk 헬퍼: pLimit(5) + Promise.allSettled로 concurrency 제한,
    성공/실패 카운트 toast (5대 원칙 #4)
  * TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체

검증:
- npm run build 통과 (새 경고 없음, label → span 교체로 a11y clean)
- npm run lint:tokens 231 유지 (신규 코드 위반 0)
- 기존 pLimit.ts (Phase A 머지) 재사용, 외부 의존성 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:34:02 +09:00
parent 8f312f50a7
commit 3375a5f1b1
3 changed files with 317 additions and 15 deletions

View File

@@ -3,7 +3,26 @@
import FormatIcon from './FormatIcon.svelte';
import TagPill from './TagPill.svelte';
let { doc, showDomain = true, selected = false, onselect = null } = $props();
let {
doc,
showDomain = true,
selected = false,
onselect = null,
// D.3 다중 선택
selectable = false,
selectedIds = new Set(),
onselectionchange = null,
} = $props();
let isChecked = $derived(selectedIds.has(doc.id));
function toggleSelection(e) {
e?.stopPropagation?.();
const next = new Set(selectedIds);
if (next.has(doc.id)) next.delete(doc.id);
else next.add(doc.id);
onselectionchange?.(next);
}
function formatDate(dateStr) {
if (!dateStr) return '';
@@ -54,17 +73,38 @@
}
</script>
<button
onclick={handleClick}
aria-label={doc.title || '문서 선택'}
class="flex items-stretch bg-surface border rounded-lg hover:border-accent transition-colors group w-full text-left overflow-hidden
{selected ? 'border-accent bg-accent/5' : 'border-default'}"
<div
class="relative flex items-stretch bg-surface border rounded-lg hover:border-accent transition-colors group overflow-hidden
{selected ? 'border-accent bg-accent/5' : 'border-default'}
{isChecked ? 'border-accent bg-accent/10' : ''}"
>
{#if selectable}
<span
class="absolute top-2 left-2 z-10 flex items-center justify-center transition-opacity
{isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}"
>
<input
type="checkbox"
checked={isChecked}
onchange={toggleSelection}
onclick={(e) => e.stopPropagation()}
class="h-4 w-4 accent-accent cursor-pointer"
aria-label="{doc.title || '문서'} 선택"
/>
</span>
{/if}
<button
type="button"
onclick={handleClick}
aria-label={doc.title || '문서 선택'}
class="flex items-stretch w-full text-left"
>
<!-- domain 색상 바 -->
<div class="w-1 shrink-0 rounded-l-lg" style="background: {domainColor}"></div>
<!-- 콘텐츠 -->
<div class="flex items-start gap-3 p-3 flex-1 min-w-0">
<div class="flex items-start gap-3 p-3 flex-1 min-w-0 {selectable ? 'pl-8' : ''}">
<!-- 포맷 아이콘 -->
<div class="shrink-0 mt-0.5 text-dim group-hover:text-accent">
<FormatIcon format={doc.file_format} size={18} />
@@ -113,4 +153,5 @@
{/if}
</div>
</div>
</button>
</button>
</div>

View File

@@ -2,7 +2,23 @@
import { goto } from '$app/navigation';
import FormatIcon from './FormatIcon.svelte';
let { items = [], selectedId = null, onselect = null } = $props();
let {
items = [],
selectedId = null,
onselect = null,
// D.3 다중 선택
selectable = false,
selectedIds = new Set(),
onselectionchange = null,
} = $props();
function toggleSelection(id, e) {
e?.stopPropagation?.();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onselectionchange?.(next);
}
let sortKey = $state('created_at');
let sortOrder = $state('desc');
@@ -95,6 +111,9 @@
<div class="w-full">
<!-- 헤더 -->
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-default text-[10px] text-dim uppercase tracking-wider">
{#if selectable}
<div class="w-6 shrink-0" aria-hidden="true"></div>
{/if}
{#each columns as col}
<button
onclick={() => toggleSort(col.key)}
@@ -110,11 +129,29 @@
<!-- 행 -->
{#each sortedItems() as doc}
<button
onclick={() => handleClick(doc)}
class="flex items-center gap-1 px-2 py-1.5 w-full text-left border-b border-default/30 hover:bg-surface transition-colors group
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}"
{@const isChecked = selectedIds.has(doc.id)}
<div
class="flex items-center gap-1 px-2 py-1.5 w-full border-b border-default/30 hover:bg-surface transition-colors group
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}
{isChecked ? 'bg-accent/10' : ''}"
>
{#if selectable}
<span class="w-6 shrink-0 flex items-center justify-center">
<input
type="checkbox"
checked={isChecked}
onchange={(e) => toggleSelection(doc.id, e)}
onclick={(e) => e.stopPropagation()}
class="h-3.5 w-3.5 accent-accent cursor-pointer"
aria-label="{doc.title || '문서'} 선택"
/>
</span>
{/if}
<button
type="button"
onclick={() => handleClick(doc)}
class="flex-1 flex items-center gap-1 text-left min-w-0"
>
<!-- 이름 -->
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="w-1 h-4 rounded-full shrink-0" style="background: {getDomainColor(doc.ai_domain)}"></span>
@@ -137,6 +174,7 @@
<div class="w-20 text-[10px] text-dim text-right">
{formatDate(doc.created_at)}
</div>
</button>
</button>
</div>
{/each}
</div>

View File

@@ -3,20 +3,39 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Info, List, LayoutGrid, ChevronLeft, X, Plus } from 'lucide-svelte';
import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree } from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import DocumentTable from '$lib/components/DocumentTable.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentMetaRail from '$lib/components/DocumentMetaRail.svelte';
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.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 { ui } from '$lib/stores/uiState.svelte';
import { useIsXl } from '$lib/composables/useMedia.svelte';
import { pLimit } from '$lib/utils/pLimit';
// D.2: 필터 칩에서 사용할 format 화이트리스트.
// 백엔드 `GET /documents/?format=...` 파라미터가 이미 받음.
const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg'];
// D.3: bulk 도메인 옵션 (Sidebar DOMAIN_COLORS와 일치)
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;
// 뷰 모드 (localStorage 기억)
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
function toggleViewMode() {
@@ -85,6 +104,12 @@
let tagPopoverOpen = $state(false);
let formatPopoverOpen = $state(false);
// D.3: 다중 선택 + bulk action 상태
let selectedIds = $state(new Set());
let bulkDomainValue = $state('');
let bulkTagValue = $state('');
let bulkBusy = $state(false);
$effect(() => {
const _p = currentPage;
const _d = filterDomain;
@@ -98,6 +123,7 @@
searchQuery = urlQ;
searchMode = urlMode;
selectedDoc = null;
selectedIds = new Set(); // D.3: URL/필터 변경 시 선택 초기화
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
if (urlQ) {
@@ -211,6 +237,79 @@
selectedDoc = selectedDoc?.id === doc.id ? null : doc;
}
// D.3: 다중 선택 핸들러
function handleSelectionChange(next) {
selectedIds = next;
}
function selectAll() {
selectedIds = new Set(items.map((d) => d.id));
}
function clearSelection() {
selectedIds = new Set();
}
// D.3: bulk action 실행기 — pLimit(5) + Promise.allSettled.
// TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체.
async function runBulk(label, taskFn) {
const ids = [...selectedIds];
if (ids.length === 0 || ids.length > MAX_SELECTION) return;
bulkBusy = true;
const limit = pLimit(5);
const results = await Promise.allSettled(
ids.map((id) => limit(() => taskFn(id)))
);
bulkBusy = false;
const success = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.length - success;
if (failed > 0) {
addToast('error', `${label}: ${failed}건 실패`);
}
if (success > 0) {
addToast('success', `${label}: ${success}건 완료`);
}
clearSelection();
loadDocuments();
}
async function bulkApplyDomain() {
if (!bulkDomainValue) return;
const domain = bulkDomainValue;
await runBulk('도메인 변경', (id) =>
api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_domain: domain }),
})
);
bulkDomainValue = '';
ui.closeModal('bulk-domain');
}
async function bulkAddTag() {
const tag = bulkTagValue.trim();
if (!tag) return;
await runBulk('태그 추가', async (id) => {
const doc = items.find((d) => d.id === id);
const existing = Array.isArray(doc?.ai_tags) ? doc.ai_tags : [];
if (existing.includes(tag)) return; // 이미 있으면 skip
const nextTags = [...existing, tag];
return api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: nextTags }),
});
});
bulkTagValue = '';
ui.closeModal('bulk-tag');
}
async function bulkDelete() {
await runBulk('삭제', (id) =>
api(`/documents/${id}?delete_file=true`, { method: 'DELETE' })
);
// ConfirmDialog가 자체적으로 닫힘
}
function handleKeydown(e) {
if (e.key === 'Escape') {
ui.handleEscape(); // drawer/modal stack 우선순위로 중앙 처리
@@ -223,6 +322,13 @@
!!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!filterFormat || !!searchQuery
);
// D.3 derived
let selectionCount = $derived(selectedIds.size);
let selectionOverLimit = $derived(selectionCount > MAX_SELECTION);
let allVisibleSelected = $derived(
items.length > 0 && items.every((d) => selectedIds.has(d.id))
);
// D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음).
let topTags = $derived.by(() => {
const counts = new Map();
@@ -449,6 +555,63 @@
{/if}
</div>
<!-- D.3: 선택 toolbar (selection.size > 0 시) -->
{#if selectionCount > 0}
<div
class="sticky top-0 z-dropdown flex flex-wrap items-center gap-2 px-4 py-2 shrink-0 bg-accent/10 border-y 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>
<button
type="button"
onclick={selectAll}
disabled={allVisibleSelected}
class="text-[11px] text-dim hover:text-text disabled:opacity-40 disabled:cursor-not-allowed"
>
전체 선택
</button>
<button
type="button"
onclick={clearSelection}
class="text-[11px] text-dim hover:text-text"
>
선택 해제
</button>
<div class="flex-1"></div>
<Button
variant="secondary"
size="sm"
icon={FolderTree}
disabled={selectionOverLimit || bulkBusy}
onclick={() => ui.openModal('bulk-domain')}
>
일괄 도메인
</Button>
<Button
variant="secondary"
size="sm"
icon={Tag}
disabled={selectionOverLimit || bulkBusy}
onclick={() => ui.openModal('bulk-tag')}
>
일괄 태그
</Button>
<Button
variant="danger"
size="sm"
icon={Trash2}
disabled={selectionOverLimit || bulkBusy}
onclick={() => ui.openModal('bulk-delete')}
>
일괄 삭제
</Button>
</div>
{/if}
<!-- 스크롤 영역 (목록) -->
<div class="flex-1 overflow-y-auto px-4">
<!-- 결과 헤더 — 카운트만. 필터는 위의 칩 row가 표시. -->
@@ -483,6 +646,9 @@
{items}
selectedId={selectedDoc?.id}
onselect={selectDoc}
selectable
{selectedIds}
onselectionchange={handleSelectionChange}
/>
{:else}
<div class="space-y-1">
@@ -492,6 +658,9 @@
showDomain={!filterDomain}
selected={selectedDoc?.id === doc.id}
onselect={selectDoc}
selectable
{selectedIds}
onselectionchange={handleSelectionChange}
/>
{/each}
</div>
@@ -560,3 +729,57 @@
/>
{/if}
</Drawer>
<!-- D.3: Bulk action modals -->
<Modal id="bulk-domain" title="일괄 도메인 변경" size="sm">
<p class="text-xs text-dim mb-3">{selectionCount}건의 문서에 새 도메인을 지정합니다.</p>
<Select
bind:value={bulkDomainValue}
options={DOMAIN_OPTIONS}
placeholder="도메인 선택"
label="도메인"
/>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('bulk-domain')}>취소</Button>
<Button
variant="primary"
size="sm"
disabled={!bulkDomainValue}
loading={bulkBusy}
onclick={bulkApplyDomain}
>
적용
</Button>
{/snippet}
</Modal>
<Modal id="bulk-tag" title="일괄 태그 추가" size="sm">
<p class="text-xs text-dim mb-3">{selectionCount}건의 문서에 태그를 추가합니다. 이미 같은 태그가 있으면 건너뜁니다.</p>
<TextInput
bind:value={bulkTagValue}
placeholder="태그 입력"
label="태그"
/>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('bulk-tag')}>취소</Button>
<Button
variant="primary"
size="sm"
disabled={!bulkTagValue.trim()}
loading={bulkBusy}
onclick={bulkAddTag}
>
추가
</Button>
{/snippet}
</Modal>
<ConfirmDialog
id="bulk-delete"
title="선택한 문서 삭제"
message="{selectionCount}건의 문서를 원본 파일과 함께 삭제합니다. 되돌릴 수 없습니다."
confirmLabel="삭제"
tone="danger"
loading={bulkBusy}
onconfirm={bulkDelete}
/>