feat(ui): BatchMoveModal + 'AI 정리하기' 버튼 (default notebook 일괄 분류)
This commit is contained in:
@@ -18,6 +18,7 @@ import { SearchBox } from './components/SearchBox.js';
|
|||||||
import { ReviewView } from './components/ReviewView.js';
|
import { ReviewView } from './components/ReviewView.js';
|
||||||
import { Sidebar } from './components/Sidebar.js';
|
import { Sidebar } from './components/Sidebar.js';
|
||||||
import { PromotionBanner } from './components/PromotionBanner.js';
|
import { PromotionBanner } from './components/PromotionBanner.js';
|
||||||
|
import { BatchMoveModal } from './components/BatchMoveModal.js';
|
||||||
import type { InboxView } from './store.js';
|
import type { InboxView } from './store.js';
|
||||||
|
|
||||||
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||||
@@ -37,6 +38,10 @@ export function App(): React.ReactElement {
|
|||||||
const counts = useInbox((s) => s.counts);
|
const counts = useInbox((s) => s.counts);
|
||||||
const setView = useInbox((s) => s.setView);
|
const setView = useInbox((s) => s.setView);
|
||||||
const searchResults = useInbox((s) => s.searchResults);
|
const searchResults = useInbox((s) => s.searchResults);
|
||||||
|
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
|
||||||
|
const notebooks = useInbox((s) => s.notebooks);
|
||||||
|
const runBatchClassify = useInbox((s) => s.runBatchClassify);
|
||||||
|
const batchClassifying = useInbox((s) => s.batchClassifying);
|
||||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||||
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
||||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||||
@@ -90,6 +95,9 @@ export function App(): React.ReactElement {
|
|||||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||||
}, [loadInitial, refreshMeta, upsertNote]);
|
}, [loadInitial, refreshMeta, upsertNote]);
|
||||||
|
|
||||||
|
// v0.4 T5 — default notebook(첫 번째) 선택 시 "AI 정리하기" 버튼 노출 조건.
|
||||||
|
const isDefaultNotebook = notebooks.length > 0 && selectedNotebookId === notebooks[0]?.id;
|
||||||
|
|
||||||
if (showOnboarding === null) return <></>;
|
if (showOnboarding === null) return <></>;
|
||||||
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
||||||
|
|
||||||
@@ -150,6 +158,19 @@ export function App(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{view === 'inbox' && isDefaultNotebook && notebooks.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { void runBatchClassify(); }}
|
||||||
|
disabled={batchClassifying}
|
||||||
|
style={{
|
||||||
|
background: '#5a3a8c', color: '#fff', border: 'none', borderRadius: 4,
|
||||||
|
padding: '4px 10px', fontSize: 11, cursor: batchClassifying ? 'not-allowed' : 'pointer',
|
||||||
|
marginLeft: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{batchClassifying ? '🪄 분석 중…' : '🪄 AI 정리하기'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
aria-label="회고 기간"
|
aria-label="회고 기간"
|
||||||
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
|
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
|
||||||
@@ -272,6 +293,7 @@ export function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<TagUndoToast />
|
<TagUndoToast />
|
||||||
</main>
|
</main>
|
||||||
|
<BatchMoveModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/renderer/inbox/components/BatchMoveModal.tsx
Normal file
98
src/renderer/inbox/components/BatchMoveModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useInbox } from '../store.js';
|
||||||
|
|
||||||
|
export function BatchMoveModal(): React.ReactElement | null {
|
||||||
|
const result = useInbox((s) => s.batchClassifyResult);
|
||||||
|
const clear = useInbox((s) => s.clearBatchClassify);
|
||||||
|
const accept = useInbox((s) => s.acceptBatchAssignments);
|
||||||
|
const notes = useInbox((s) => s.notes);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// result 변할 때 → 매칭된 (notebookId != null) noteId 모두 default 로 select
|
||||||
|
useEffect(() => {
|
||||||
|
if (result === null) return;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const a of result.assignments) {
|
||||||
|
if (a.notebookId !== null) ids.add(a.noteId);
|
||||||
|
}
|
||||||
|
setSelectedIds(ids);
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
if (result === null) return null;
|
||||||
|
|
||||||
|
const actionable = result.assignments.filter((a) => a.notebookId !== null);
|
||||||
|
|
||||||
|
function toggle(noteId: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
if (n.has(noteId)) n.delete(noteId);
|
||||||
|
else n.add(noteId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNote(id: string): string {
|
||||||
|
const n = notes.find((nn) => nn.id === id);
|
||||||
|
return n?.aiTitle ?? n?.rawText.slice(0, 60) ?? '(노트)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
const accepted = actionable
|
||||||
|
.filter((a) => selectedIds.has(a.noteId) && a.notebookId !== null)
|
||||||
|
.map((a) => ({ noteId: a.noteId, notebookId: a.notebookId! }));
|
||||||
|
await accept(accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={clear}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 130 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ background: '#fff', padding: 20, borderRadius: 8, width: 560, maxHeight: '80vh', overflow: 'auto' }}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}>AI 분류 제안</h3>
|
||||||
|
{actionable.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: '#666' }}>
|
||||||
|
현재 분류할 만한 노트를 찾지 못했어요. 노트북을 더 만들거나 노트가 누적된 뒤 다시 시도해보세요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
|
||||||
|
체크된 노트만 이동합니다. ({selectedIds.size}/{actionable.length})
|
||||||
|
</p>
|
||||||
|
<div style={{ maxHeight: '50vh', overflow: 'auto', border: '1px solid #eee', borderRadius: 4 }}>
|
||||||
|
{actionable.map((a) => (
|
||||||
|
<label
|
||||||
|
key={a.noteId}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid #f0f0f0', cursor: 'pointer', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(a.noteId)}
|
||||||
|
onChange={() => toggle(a.noteId)}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{findNote(a.noteId)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#0a4b80', fontSize: 11 }}>→ {a.notebookName}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
|
||||||
|
<button onClick={clear} style={{ padding: '5px 12px', fontSize: 12 }}>취소</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { void onConfirm(); }}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{selectedIds.size}건 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity, BatchClassifyResult } from '@shared/types';
|
||||||
import { inboxApi, notebookApi } from './api.js';
|
import { inboxApi, notebookApi } from './api.js';
|
||||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||||
|
|
||||||
@@ -56,6 +56,9 @@ interface InboxState {
|
|||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
// v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
|
// v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
|
||||||
promotionCandidates: PromotionCandidate[];
|
promotionCandidates: PromotionCandidate[];
|
||||||
|
// v0.4 T5 — AI batch classify state.
|
||||||
|
batchClassifyResult: BatchClassifyResult | null;
|
||||||
|
batchClassifying: boolean;
|
||||||
loadInitial: () => Promise<void>;
|
loadInitial: () => Promise<void>;
|
||||||
refreshMeta: () => Promise<void>;
|
refreshMeta: () => Promise<void>;
|
||||||
upsertNote: (note: Note) => void;
|
upsertNote: (note: Note) => void;
|
||||||
@@ -98,6 +101,10 @@ interface InboxState {
|
|||||||
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
|
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
|
||||||
snoozePromotion: () => Promise<void>;
|
snoozePromotion: () => Promise<void>;
|
||||||
dismissPromotion: (tag: string) => Promise<void>;
|
dismissPromotion: (tag: string) => Promise<void>;
|
||||||
|
// v0.4 T5 — AI batch classify actions.
|
||||||
|
runBatchClassify: () => Promise<void>;
|
||||||
|
clearBatchClassify: () => void;
|
||||||
|
acceptBatchAssignments: (accepted: Array<{ noteId: string; notebookId: string }>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyContinuity: WeeklyContinuity = {
|
const emptyContinuity: WeeklyContinuity = {
|
||||||
@@ -133,6 +140,8 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
sidebarWidth: 240,
|
sidebarWidth: 240,
|
||||||
promotionCandidates: [],
|
promotionCandidates: [],
|
||||||
|
batchClassifyResult: null,
|
||||||
|
batchClassifying: false,
|
||||||
async loadInitial() {
|
async loadInitial() {
|
||||||
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
|
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
@@ -545,5 +554,25 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
async dismissPromotion(tag) {
|
async dismissPromotion(tag) {
|
||||||
await inboxApi.addPromotionDismissedTag(tag);
|
await inboxApi.addPromotionDismissedTag(tag);
|
||||||
set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
|
set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
|
||||||
|
},
|
||||||
|
// v0.4 T5 — AI batch classify actions.
|
||||||
|
async runBatchClassify() {
|
||||||
|
set({ batchClassifying: true });
|
||||||
|
try {
|
||||||
|
const r = await inboxApi.batchClassifyDefault();
|
||||||
|
set({ batchClassifyResult: r, batchClassifying: false });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[inbox] batchClassify failed', e);
|
||||||
|
set({ batchClassifying: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearBatchClassify() {
|
||||||
|
set({ batchClassifyResult: null });
|
||||||
|
},
|
||||||
|
async acceptBatchAssignments(accepted) {
|
||||||
|
await Promise.all(accepted.map((a) => notebookApi.moveNote(a.noteId, a.notebookId)));
|
||||||
|
set({ batchClassifyResult: null });
|
||||||
|
await get().refreshMeta();
|
||||||
|
await get().loadByView(get().view as 'inbox' | 'completed' | 'trash');
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
143
tests/unit/BatchMoveModal.test.tsx
Normal file
143
tests/unit/BatchMoveModal.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const { mockMoveNote, mockBatchClassifyDefault } = vi.hoisted(() => ({
|
||||||
|
mockMoveNote: vi.fn(async () => ({ ok: true as const })),
|
||||||
|
mockBatchClassifyDefault: vi.fn(async () => ({ assignments: [] }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||||
|
inboxApi: {
|
||||||
|
batchClassifyDefault: mockBatchClassifyDefault,
|
||||||
|
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||||
|
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })),
|
||||||
|
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||||
|
getPendingCount: vi.fn(async () => 0),
|
||||||
|
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||||
|
getTodayCount: vi.fn(async () => 0),
|
||||||
|
getTrashCount: vi.fn(async () => 0),
|
||||||
|
listExpired: vi.fn(async () => []),
|
||||||
|
getFailedCount: vi.fn(async () => 0),
|
||||||
|
listRecallCandidate: vi.fn(async () => null),
|
||||||
|
listByStatus: vi.fn(async () => [])
|
||||||
|
},
|
||||||
|
notebookApi: {
|
||||||
|
moveNote: mockMoveNote,
|
||||||
|
list: vi.fn(async () => [])
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { BatchMoveModal } from '../../src/renderer/inbox/components/BatchMoveModal';
|
||||||
|
import { useInbox } from '../../src/renderer/inbox/store';
|
||||||
|
|
||||||
|
const baseNote = {
|
||||||
|
id: 'n1',
|
||||||
|
rawText: '테스트 노트 내용',
|
||||||
|
aiTitle: 'AI 제목',
|
||||||
|
aiSummary: null,
|
||||||
|
aiStatus: 'done' as const,
|
||||||
|
aiError: null,
|
||||||
|
aiProvider: null,
|
||||||
|
aiGeneratedAt: null,
|
||||||
|
titleEditedByUser: false,
|
||||||
|
summaryEditedByUser: false,
|
||||||
|
userIntent: null,
|
||||||
|
intentPromptedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
dueDateEditedByUser: false,
|
||||||
|
deletedAt: null,
|
||||||
|
lastRecalledAt: null,
|
||||||
|
recallDismissedAt: null,
|
||||||
|
status: 'active' as const,
|
||||||
|
statusChangedAt: null,
|
||||||
|
moveReason: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
tags: [],
|
||||||
|
media: [],
|
||||||
|
notebookId: 'nb-default'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BatchMoveModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
useInbox.setState({
|
||||||
|
batchClassifyResult: null,
|
||||||
|
batchClassifying: false,
|
||||||
|
notes: [],
|
||||||
|
notebooks: []
|
||||||
|
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batchClassifyResult null 시 null 반환', () => {
|
||||||
|
useInbox.setState({ batchClassifyResult: null } as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
const { container } = render(<BatchMoveModal />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actionable 0건 시 "찾지 못했어요" 메시지 표시', () => {
|
||||||
|
useInbox.setState({
|
||||||
|
batchClassifyResult: { assignments: [{ noteId: 'n1', notebookId: null, notebookName: null }] }
|
||||||
|
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
render(<BatchMoveModal />);
|
||||||
|
expect(screen.getByText(/찾지 못했어요/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actionable 노트 표시 + checkbox 기본 체크', () => {
|
||||||
|
useInbox.setState({
|
||||||
|
batchClassifyResult: {
|
||||||
|
assignments: [
|
||||||
|
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
notes: [{ ...baseNote, id: 'n1', aiTitle: 'AI 제목' }]
|
||||||
|
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
render(<BatchMoveModal />);
|
||||||
|
expect(screen.getByText('AI 제목')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/→ 업무/)).toBeInTheDocument();
|
||||||
|
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkbox toggle 시 selectedIds 변경 (체크 해제 후 확인 버튼 비활성)', () => {
|
||||||
|
useInbox.setState({
|
||||||
|
batchClassifyResult: {
|
||||||
|
assignments: [
|
||||||
|
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
notes: [{ ...baseNote, id: 'n1' }]
|
||||||
|
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
render(<BatchMoveModal />);
|
||||||
|
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /건 이동/ });
|
||||||
|
expect(confirmBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('확인 클릭 → acceptBatchAssignments 호출 후 modal 닫힘', async () => {
|
||||||
|
const mockAccept = vi.fn(async () => {});
|
||||||
|
useInbox.setState({
|
||||||
|
batchClassifyResult: {
|
||||||
|
assignments: [
|
||||||
|
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
notes: [{ ...baseNote, id: 'n1' }],
|
||||||
|
acceptBatchAssignments: mockAccept
|
||||||
|
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||||
|
render(<BatchMoveModal />);
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /건 이동/ });
|
||||||
|
fireEvent.click(confirmBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAccept).toHaveBeenCalledWith([{ noteId: 'n1', notebookId: 'nb1' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user