void;
- onMoved: (status: NoteStatus, reason: string | null) => void;
-}
-
-/**
- * v0.2.9 Cut B Task 7 — 메모 이동 Modal.
- *
- * 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
- */
-export function MoveStatusModal({
- noteId,
- onClose,
- onMoved
-}: Props): React.ReactElement {
- const [reason, setReason] = useState('');
- const [recommendation, setRecommendation] = useState<{
- status: NoteStatus;
- rationale: string;
- } | null>(null);
- const [classifying, setClassifying] = useState(false);
-
- async function move(status: NoteStatus): Promise {
- const trimmedReason = reason.trim() === '' ? null : reason.trim();
- await inboxApi.setStatus(noteId, status, trimmedReason);
- onMoved(status, trimmedReason);
- }
-
- async function classify(): Promise {
- setClassifying(true);
- setRecommendation(null);
- try {
- const r = await inboxApi.classifyStatus(noteId, reason);
- setRecommendation({ status: r.recommended, rationale: r.rationale });
- } finally {
- setClassifying(false);
- }
- }
-
- return (
- (null);
+ // v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown. dropdown 항목 클릭 = 해당 status 로 즉시 이동.
const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
@@ -129,6 +129,25 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
React.useEffect(() => { setLocal(note); }, [note]);
+ // 이동 dropdown 외부 클릭 / Escape 로 닫기. menuOpen=true 일 때만 listener 활성.
+ useEffect(() => {
+ if (!menuOpen) return;
+ function onMouseDown(e: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setMenuOpen(false);
+ }
+ }
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') setMenuOpen(false);
+ }
+ document.addEventListener('mousedown', onMouseDown);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [menuOpen]);
+
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function saveTitle(next: string) {
@@ -426,7 +445,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
- setMoveTarget(null)}
- onMoved={(newStatus, reason) => {
- const updated = { ...local, status: newStatus, moveReason: reason };
- setLocal(updated);
- onUpdated(updated);
- // inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
- if (newStatus !== local.status) onDeleted?.();
- setMoveTarget(null);
- }}
- />
- )}
{showRevisions && (
= {
export function ReviewView({ period }: Props): React.ReactElement {
const reviewData = useInbox((s) => s.reviewData);
+ const setView = useInbox((s) => s.setView);
+ const backButton = (
+ setView('inbox')}
+ style={{
+ background: 'transparent',
+ border: 'none',
+ fontSize: 14,
+ cursor: 'pointer',
+ color: '#0a4b80',
+ padding: 0
+ }}
+ >
+ ← 돌아가기
+
+ );
if (!reviewData) {
- return
-
- );
-}
-
-export function statusLabel(s: NoteStatus): string {
- switch (s) {
- case 'active':
- return '활성';
- case 'completed':
- return '완료';
- case 'archived':
- return '보관';
- case 'trashed':
- return '휴지통';
- }
-}
-
-/**
- * status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
- * 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
- */
-export function statusLabelWithParticle(s: NoteStatus): string {
- const label = statusLabel(s);
- const last = label.charCodeAt(label.length - 1);
- // 한글 syllable block 외 → "로" default
- if (last < 0xAC00 || last > 0xD7A3) return `${label}로`;
- const jongseong = (last - 0xAC00) % 28;
- return jongseong === 0 ? `${label}로` : `${label}으로`;
-}
diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx
index 64e8d49..606f72c 100644
--- a/src/renderer/inbox/components/NoteCard.tsx
+++ b/src/renderer/inbox/components/NoteCard.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
@@ -6,7 +6,7 @@ import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
-import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
+import { statusLabelWithParticle } from './statusLabel.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -116,9 +116,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
- // v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
- const [moveTarget, setMoveTarget] = useState
-
- 메모 이동
-
+
- {moveTarget !== null && (
- 불러오는 중…
;
+ return (
+
+
+ );
}
const max = reviewData.tagCounts[0]?.count ?? 1;
return (
{backButton}
+ 불러오는 중…
+
- ;
+ loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise;
toggleShowTrash: () => Promise;
loadTrash: () => Promise;
restoreNote: (id: string) => Promise;
@@ -104,7 +104,9 @@ export const useInbox = create((set, get) => ({
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
- inboxApi.listNotes({ limit: 50 }),
+ // inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
+ // listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
+ inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
@@ -182,13 +184,18 @@ export const useInbox = create((set, get) => ({
else get().setView('inbox');
},
setView(view) {
+ // view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
- showSettings: view === 'settings'
+ showSettings: view === 'settings',
+ searchResults: null,
+ searchQuery: '',
+ tagFilter: null
});
- // settings/inbox 외 status view 면 해당 status fetch.
- if (view === 'completed' || view === 'archived' || view === 'trash') {
+ // status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
+ // 이전 status 로 stale 한 상태이므로 재로드 필요.
+ if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
@@ -197,7 +204,8 @@ export const useInbox = create((set, get) => ({
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
- const status = view === 'trash' ? 'trashed' : view;
+ const status =
+ view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
diff --git a/tests/unit/MoveStatusModal.test.tsx b/tests/unit/MoveStatusModal.test.tsx
deleted file mode 100644
index c083a86..0000000
--- a/tests/unit/MoveStatusModal.test.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-// @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';
-
-const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
- mockSetStatus: vi.fn(async () => ({ ok: true as const })),
- mockClassify: vi.fn(async () => ({
- recommended: 'completed' as const,
- rationale: '결재 끝'
- }))
-}));
-
-vi.mock('../../src/renderer/inbox/api.js', () => ({
- inboxApi: {
- setStatus: mockSetStatus,
- classifyStatus: mockClassify
- }
-}));
-
-import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
-
-describe('MoveStatusModal', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- cleanup();
- });
-
- it('renders reason textarea + 4 buttons + AI classify button', () => {
- render(
-
- );
- expect(screen.getByRole('textbox')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
- });
-
- it('clicking 완료 calls setStatus with reason', async () => {
- const onMoved = vi.fn();
- render(
-
- );
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
- fireEvent.click(screen.getByRole('button', { name: '완료' }));
- await waitFor(() => {
- expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
- expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
- });
- });
-
- it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
- const onMoved = vi.fn();
- render(
-
- );
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
- fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
- await screen.findByText(/AI 추천/);
- expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: /확정/ }));
- await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
- });
-
- it('빈 사유 → null reason 전달', async () => {
- const onMoved = vi.fn();
- render(
-
- );
- fireEvent.click(screen.getByRole('button', { name: '보관' }));
- await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
- });
-});
diff --git a/tests/unit/NoteCard.test.tsx b/tests/unit/NoteCard.test.tsx
index a2899d4..b6f0635 100644
--- a/tests/unit/NoteCard.test.tsx
+++ b/tests/unit/NoteCard.test.tsx
@@ -32,10 +32,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
}
}));
+const mockRefreshMeta = vi.fn();
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
- { getState: () => ({ setTagFilter: vi.fn() }) }
+ { getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
)
}));
@@ -143,19 +144,45 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
});
- it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
+ it('메뉴 항목 클릭 → 즉시 setStatus 호출 (modal 없음)', async () => {
const onUpdated = vi.fn();
- render( );
+ const onDeleted = vi.fn();
+ render(
+
+ );
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
- // Modal 의 dialog role 등장
- expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
- // Modal 내부의 "완료" 버튼 클릭 → setStatus
- fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
+ // status 변경 → 현재 view (inbox) 에서 제거되어야 함.
+ expect(onDeleted).toHaveBeenCalled();
+ // 헤더 탭 count 동기화.
+ expect(mockRefreshMeta).toHaveBeenCalled();
});
+ // modal 미존재 검증.
+ expect(screen.queryByRole('dialog', { name: '이동' })).toBeNull();
+ });
+
+ it('이동 메뉴 외부 클릭 시 dropdown 닫힘', () => {
+ render( {}} mode="inbox" />);
+ fireEvent.click(screen.getByRole('button', { name: '이동' }));
+ expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
+ fireEvent.mouseDown(document.body);
+ expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
+ });
+
+ it('이동 메뉴 열린 상태에서 Escape → dropdown 닫힘', () => {
+ render( {}} mode="inbox" />);
+ fireEvent.click(screen.getByRole('button', { name: '이동' }));
+ expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
});
});
diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx
index ea8b313..02d3ffc 100644
--- a/tests/unit/SettingsPage.test.tsx
+++ b/tests/unit/SettingsPage.test.tsx
@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
+ // setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
+ listByStatus: vi.fn(async () => []),
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
diff --git a/tests/unit/store.showSettings.test.ts b/tests/unit/store.showSettings.test.ts
index f7bbd9d..811246c 100644
--- a/tests/unit/store.showSettings.test.ts
+++ b/tests/unit/store.showSettings.test.ts
@@ -3,6 +3,7 @@ import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
+ listByStatus: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
{periodLabel[period]} 회고
+
+ {backButton}
+
{periodLabel[period]} 회고
+
총 {reviewData.totalCount}건
diff --git a/src/renderer/inbox/components/statusLabel.ts b/src/renderer/inbox/components/statusLabel.ts
new file mode 100644
index 0000000..30bd11b
--- /dev/null
+++ b/src/renderer/inbox/components/statusLabel.ts
@@ -0,0 +1,26 @@
+import type { NoteStatus } from '@shared/types';
+
+export function statusLabel(s: NoteStatus): string {
+ switch (s) {
+ case 'active':
+ return '활성';
+ case 'completed':
+ return '완료';
+ case 'archived':
+ return '보관';
+ case 'trashed':
+ return '휴지통';
+ }
+}
+
+/**
+ * status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
+ * 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
+ */
+export function statusLabelWithParticle(s: NoteStatus): string {
+ const label = statusLabel(s);
+ const last = label.charCodeAt(label.length - 1);
+ if (last < 0xAC00 || last > 0xD7A3) return `${label}로`;
+ const jongseong = (last - 0xAC00) % 28;
+ return jongseong === 0 ? `${label}로` : `${label}으로`;
+}
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
index 10b1ff3..3f69d27 100644
--- a/src/renderer/inbox/store.ts
+++ b/src/renderer/inbox/store.ts
@@ -52,7 +52,7 @@ interface InboxState {
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
- loadByView: (view: 'completed' | 'archived' | 'trash') => Promise