Skip to content

Commit dd2c322

Browse files
authored
[4-3] 거래 입력 메모 자동완성 구현 (#113)
* 거래 입력 메모 자동완성 기능 추가 (#57) * 리뷰 반영: 최근 메모 요청 레이스 가드 및 조회 컬럼 최적화
1 parent 95b2951 commit dd2c322

4 files changed

Lines changed: 101 additions & 11 deletions

File tree

apps/api/src/transactions/transactions.controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export class TransactionsController {
4848
return await this.transactionsService.findRecentCategories(ledgerId, user.userId, type);
4949
}
5050

51+
@Get('recent-memos')
52+
@ApiOperation({ summary: '카테고리별 최근 메모 자동완성 목록 조회' })
53+
async recentMemos(
54+
@CurrentUser() user: AuthUser,
55+
@Param('ledgerId', ParseIntPipe) ledgerId: number,
56+
@Query('categoryId', ParseIntPipe) categoryId: number,
57+
) {
58+
return await this.transactionsService.findRecentMemos(ledgerId, user.userId, categoryId);
59+
}
60+
5161
@Post()
5262
@ApiOperation({ summary: '거래 생성' })
5363
async create(

apps/api/src/transactions/transactions.service.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,34 @@ export class TransactionsService {
182182
return categoryIds.map((id) => map.get(id)).filter(Boolean);
183183
}
184184

185+
async findRecentMemos(ledgerId: number, userId: number, categoryId: number) {
186+
await this.assertMembership(ledgerId, userId);
187+
188+
const txs = await this.db.query.transactions.findMany({
189+
where: and(
190+
eq(transactions.householdLedgerId, ledgerId),
191+
eq(transactions.categoryId, categoryId),
192+
),
193+
columns: { memo: true },
194+
orderBy: (table) => [desc(table.transactionDate), desc(table.id)],
195+
limit: 50,
196+
});
197+
198+
const uniqueMemos: string[] = [];
199+
const seen = new Set<string>();
200+
201+
for (const tx of txs) {
202+
const trimmedMemo = tx.memo?.trim();
203+
if (!trimmedMemo) continue;
204+
if (seen.has(trimmedMemo)) continue;
205+
seen.add(trimmedMemo);
206+
uniqueMemos.push(trimmedMemo);
207+
if (uniqueMemos.length >= 8) break;
208+
}
209+
210+
return uniqueMemos;
211+
}
212+
185213
async create(ledgerId: number, userId: number, dto: CreateTransactionDto) {
186214
await this.assertMembership(ledgerId, userId);
187215

apps/web/src/routes/transactions.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ function TransactionsPage() {
180180
const [detailTransaction, setDetailTransaction] = useState<Transaction | null>(null)
181181
const [swipedTxId, setSwipedTxId] = useState<number | null>(null)
182182
const touchStartX = useRef<number | null>(null)
183+
const recentMemosRequestId = useRef(0)
183184

184185
const [searchOpen, setSearchOpen] = useState(false)
185186
const [searchKeyword, setSearchKeyword] = useState('')
@@ -195,6 +196,7 @@ function TransactionsPage() {
195196
const [schedules, setSchedules] = useState<Schedule[]>([])
196197
const [installments, setInstallments] = useState<Installment[]>([])
197198
const [recentCategories, setRecentCategories] = useState<Category[]>([])
199+
const [recentMemos, setRecentMemos] = useState<string[]>([])
198200

199201
const goPrevMonth = () => {
200202
const current = new Date(selectedYear, selectedMonth - 1, 1)
@@ -296,6 +298,25 @@ function TransactionsPage() {
296298
setRecentCategories(Array.isArray(payload) ? payload : (payload.data ?? []))
297299
}, [ledgerId, tab])
298300

301+
const fetchRecentMemos = useCallback(async () => {
302+
const requestId = recentMemosRequestId.current + 1
303+
recentMemosRequestId.current = requestId
304+
setRecentMemos([])
305+
306+
if (!ledgerId || tab === 'TRANSFER' || !categoryId) {
307+
return
308+
}
309+
310+
const response = await apiFetch(
311+
`/api/ledgers/${ledgerId}/transactions/recent-memos?categoryId=${encodeURIComponent(categoryId)}`,
312+
)
313+
if (!response.ok) return
314+
315+
const payload = (await response.json()) as { data?: string[] } | string[]
316+
if (requestId !== recentMemosRequestId.current) return
317+
setRecentMemos(Array.isArray(payload) ? payload : (payload.data ?? []))
318+
}, [categoryId, ledgerId, tab])
319+
299320
useEffect(() => {
300321
void fetchTransactions()
301322
void fetchMetadata()
@@ -311,6 +332,11 @@ function TransactionsPage() {
311332
void fetchRecentCategories()
312333
}, [entryOpen, fetchRecentCategories, tab])
313334

335+
useEffect(() => {
336+
if (!entryOpen) return
337+
void fetchRecentMemos()
338+
}, [entryOpen, fetchRecentMemos])
339+
314340
useEffect(() => {
315341
setCalendarDateSheetOpen(false)
316342
setSelectedCalendarDate(null)
@@ -361,6 +387,7 @@ function TransactionsPage() {
361387
setIsFixed(false)
362388
setTab('EXPENSE')
363389
setEditingTransactionId(null)
390+
setRecentMemos([])
364391
}
365392

366393
const openCreate = () => {
@@ -550,6 +577,13 @@ function TransactionsPage() {
550577

551578
const monthLabel = `${selectedYear}${selectedMonth}월`
552579
const categoryOptions = categories.filter((item) => item.type === tab)
580+
const memoSuggestions = useMemo(() => {
581+
const keyword = memo.trim().toLowerCase()
582+
if (!keyword || !categoryId || tab === 'TRANSFER') return []
583+
return recentMemos
584+
.filter((item) => item.toLowerCase().includes(keyword) && item.toLowerCase() !== keyword)
585+
.slice(0, 5)
586+
}, [categoryId, memo, recentMemos, tab])
553587
const accountNameById = useMemo(() => new Map(accounts.map((a) => [a.id, a.name])), [accounts])
554588
const installmentById = useMemo(() => new Map(installments.map((item) => [item.id, item])), [installments])
555589
const categoryNameById = useMemo(
@@ -1930,13 +1964,30 @@ function TransactionsPage() {
19301964
</select>
19311965
) : null}
19321966

1933-
<input
1934-
value={memo}
1935-
onChange={(e) => setMemo(e.target.value)}
1936-
placeholder="메모 (선택)"
1937-
className="rounded-md border px-3 py-2 text-sm"
1938-
aria-label="메모"
1939-
/>
1967+
<div className="relative">
1968+
<input
1969+
value={memo}
1970+
onChange={(e) => setMemo(e.target.value)}
1971+
placeholder="메모 (선택)"
1972+
className="w-full rounded-md border px-3 py-2 text-sm"
1973+
aria-label="메모"
1974+
autoComplete="off"
1975+
/>
1976+
{memoSuggestions.length > 0 ? (
1977+
<div className="absolute left-0 right-0 top-[calc(100%+4px)] z-10 rounded-md border bg-popover shadow-sm">
1978+
{memoSuggestions.map((suggestion) => (
1979+
<button
1980+
key={suggestion}
1981+
type="button"
1982+
onClick={() => setMemo(suggestion)}
1983+
className="w-full px-3 py-2 text-left text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
1984+
>
1985+
{suggestion}
1986+
</button>
1987+
))}
1988+
</div>
1989+
) : null}
1990+
</div>
19401991

19411992
<label className="flex items-center gap-2 text-sm">
19421993
<input type="checkbox" checked={isFixed} onChange={(e) => setIsFixed(e.target.checked)} />

memories/task.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -521,14 +521,15 @@
521521
- **의존성:** 1-8, 4-1
522522

523523
### [4-3] 거래 입력 자동완성
524-
- **상태:** 대기
524+
- **상태:** 완료
525525
- **범위:** `apps/web`
526526
- **설명:** 거래 입력 바텀시트 memo 필드에 동일 카테고리 최근 거래 memo 기반 자동완성 제안. 카테고리 선택 후 memo 입력 시 드롭다운으로 최근 memo 목록 표시.
527+
- **진행 메모 (2026-03-31):** `apps/api/src/transactions``GET /api/ledgers/:ledgerId/transactions/recent-memos?categoryId=...`를 추가해 카테고리별 최근 메모(중복 제거, 최신순 최대 8개)를 조회하도록 반영. `apps/web/src/routes/transactions.tsx` 거래 입력 시트의 memo 입력 아래 자동완성 드롭다운을 연결해 항목 선택 시 자동 채움되도록 구현.
527528
- **참조:** product-planning §3-3
528529
- **완료 조건:**
529-
- 카테고리 선택 후 memo 입력 시 자동완성 드롭다운 표시
530-
- 드롭다운 항목 선택 시 memo 자동 채움
531-
- 최근 거래 없으면 드롭다운 미표시
530+
- 카테고리 선택 후 memo 입력 시 자동완성 드롭다운 표시
531+
- 드롭다운 항목 선택 시 memo 자동 채움
532+
- 최근 거래 없으면 드롭다운 미표시
532533
- **의존성:** 1-14
533534

534535
### [4-4] 회계기준일 집계 적용

0 commit comments

Comments
 (0)