Skip to content

Commit c286ab6

Browse files
sscoderaticlaude
andauthored
feat: 거래에 선택적 거래처(counterparty) 필드 추가 (#123)
* feat: 거래에 선택적 거래처(counterparty) 필드 추가 지출 거래에서 거래 상대방(가게, 업체 등)을 기록할 수 있도록 선택적 counterparty 필드를 추가한다. 키워드 검색 시 메모와 거래처를 함께 검색하며, 목록에서는 메모(거래처) 형태로 표시된다. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: PR 리뷰 반영 - counterparty 유효성 검증 및 탭 제한 - BE DTO에 @maxlength(100) 추가하여 DB varchar(100) 제약과 일치 - FE input에 maxLength={100} 추가 - EXPENSE 탭이 아닐 때 counterparty를 body에 포함하지 않도록 수정 - 탭 전환 시 EXPENSE가 아니면 counterparty state 초기화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43a957e commit c286ab6

File tree

12 files changed

+1199
-6
lines changed

12 files changed

+1199
-6
lines changed

apps/api/src/dashboard/dashboard.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export class DashboardService {
237237
type: tx.type,
238238
amount: tx.amount,
239239
memo: tx.memo ?? null,
240+
counterparty: tx.counterparty ?? null,
240241
category: tx.category
241242
? {
242243
major: tx.category.major,

apps/api/src/dashboard/dto/dashboard-response.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class RecentTransactionDto {
5858
@ApiProperty({ description: '메모', nullable: true })
5959
memo: string | null;
6060

61+
@ApiProperty({ description: '거래처', nullable: true })
62+
counterparty: string | null;
63+
6164
@ApiProperty({ description: '카테고리 정보', nullable: true })
6265
category: { major: string; minor: string } | null;
6366

apps/api/src/transactions/dto/create-transaction.dto.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
2+
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
33

44
export class CreateTransactionDto {
55
@ApiProperty({ example: '2026-03-29' })
@@ -39,6 +39,12 @@ export class CreateTransactionDto {
3939
@IsString()
4040
memo?: string;
4141

42+
@ApiProperty({ required: false })
43+
@IsOptional()
44+
@IsString()
45+
@MaxLength(100)
46+
counterparty?: string;
47+
4248
@ApiProperty({ required: false })
4349
@IsOptional()
4450
@IsInt()

apps/api/src/transactions/dto/update-transaction.dto.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
2+
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
33

44
export class UpdateTransactionDto {
55
@ApiProperty({ required: false, example: '2026-03-29' })
@@ -41,4 +41,10 @@ export class UpdateTransactionDto {
4141
@IsOptional()
4242
@IsString()
4343
memo?: string;
44+
45+
@ApiProperty({ required: false })
46+
@IsOptional()
47+
@IsString()
48+
@MaxLength(100)
49+
counterparty?: string;
4450
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
householdLedgerMembers,
1515
ilike,
1616
inArray,
17+
or,
1718
sql,
1819
transactions,
1920
type DrizzleClient,
@@ -138,7 +139,12 @@ export class TransactionsService {
138139
const conditions: any[] = [eq(transactions.householdLedgerId, ledgerId)];
139140

140141
if (query.type) conditions.push(eq(transactions.type, query.type));
141-
if (query.keyword?.trim()) conditions.push(ilike(transactions.memo, `%${query.keyword.trim()}%`));
142+
if (query.keyword?.trim()) {
143+
const pattern = `%${query.keyword.trim()}%`;
144+
conditions.push(
145+
or(ilike(transactions.memo, pattern), ilike(transactions.counterparty, pattern))!,
146+
);
147+
}
142148

143149
if (query.year && query.month) {
144150
const mm = String(query.month).padStart(2, '0');
@@ -233,6 +239,7 @@ export class TransactionsService {
233239
accountOutId: dto.accountOutId ?? null,
234240
isFixed: dto.isFixed ?? false,
235241
memo: dto.memo ?? null,
242+
counterparty: dto.counterparty ?? null,
236243
installmentId: dto.installmentId ?? null,
237244
createdAt: new Date(),
238245
updatedAt: new Date(),
@@ -275,6 +282,7 @@ export class TransactionsService {
275282
...(dto.accountOutId !== undefined && { accountOutId: dto.accountOutId }),
276283
...(dto.isFixed !== undefined && { isFixed: dto.isFixed }),
277284
...(dto.memo !== undefined && { memo: dto.memo }),
285+
...(dto.counterparty !== undefined && { counterparty: dto.counterparty }),
278286
type: resolvedType,
279287
updatedAt: new Date(),
280288
};

apps/web/src/components/dashboard/RecentTransactionsList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function RecentTransactionsList({ data }: Props) {
3636
</span>
3737
</div>
3838
{tx.memo && <p className="text-sm text-muted-foreground truncate mt-1">{tx.memo}</p>}
39+
{tx.counterparty && <p className="text-sm text-muted-foreground truncate mt-0.5">{tx.counterparty}</p>}
3940
<p className="text-xs text-muted-foreground mt-1">{tx.transactionDate}</p>
4041
</div>
4142
<div className="text-right flex-shrink-0">

apps/web/src/lib/dashboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type DashboardData = {
2727
type: string
2828
amount: number
2929
memo: string | null
30+
counterparty: string | null
3031
category: { major: string; minor: string } | null
3132
accountIn: { id: number; name: string } | null
3233
accountOut: { id: number; name: string } | null

apps/web/src/routes/transactions.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type Transaction = {
6161
accountOutId: number | null
6262
isFixed: boolean
6363
memo: string | null
64+
counterparty: string | null
6465
installmentId: number | null
6566
createdBy: number | null
6667
createdAt: string | null
@@ -185,6 +186,7 @@ function TransactionsPage() {
185186
const [accountInId, setAccountInId] = useState('')
186187
const [accountOutId, setAccountOutId] = useState('')
187188
const [memo, setMemo] = useState('')
189+
const [counterparty, setCounterparty] = useState('')
188190
const [isFixed, setIsFixed] = useState(false)
189191
const [submitting, setSubmitting] = useState(false)
190192

@@ -397,6 +399,7 @@ function TransactionsPage() {
397399
setAccountInId('')
398400
setAccountOutId('')
399401
setMemo('')
402+
setCounterparty('')
400403
setIsFixed(false)
401404
setTab('EXPENSE')
402405
setEditingTransactionId(null)
@@ -435,6 +438,7 @@ function TransactionsPage() {
435438
setTransactionDate(tx.transactionDate)
436439
setAmount(String(tx.amount))
437440
setMemo(tx.memo ?? '')
441+
setCounterparty(tx.counterparty ?? '')
438442
setIsFixed(!!tx.isFixed)
439443

440444
if (tx.type === 'INCOME') {
@@ -479,8 +483,10 @@ function TransactionsPage() {
479483

480484
if (editingTransactionId) {
481485
body.memo = memo.trim() ? memo.trim() : null
486+
body.counterparty = tab === 'EXPENSE' && counterparty.trim() ? counterparty.trim() : null
482487
} else {
483488
body.memo = memo.trim() || undefined
489+
if (tab === 'EXPENSE') body.counterparty = counterparty.trim() || undefined
484490
}
485491

486492
if (tab === 'INCOME') {
@@ -627,7 +633,11 @@ function TransactionsPage() {
627633
const getTransactionPreview = (tx: Transaction) => {
628634
const isTransfer = isTransferType(tx.type)
629635
const typeLabel = getTransactionTypeLabel(tx.type)
630-
const memoText = tx.memo?.trim() ?? ''
636+
const rawMemo = tx.memo?.trim() ?? ''
637+
const counterpartyText = tx.counterparty?.trim() ?? ''
638+
const memoText = rawMemo && counterpartyText
639+
? `${rawMemo}(${counterpartyText})`
640+
: rawMemo || counterpartyText
631641

632642
const categoryLabel = tx.categoryId
633643
? (categoryNameById.get(tx.categoryId) ?? '알 수 없는 카테고리')
@@ -675,9 +685,10 @@ function TransactionsPage() {
675685
const kw = debouncedKeyword.trim().toLowerCase()
676686
result = result.filter((tx) => {
677687
const memoMatch = tx.memo?.toLowerCase().includes(kw) ?? false
688+
const counterpartyMatch = tx.counterparty?.toLowerCase().includes(kw) ?? false
678689
const categoryName = tx.categoryId ? (categoryNameById.get(tx.categoryId) ?? '') : ''
679690
const categoryMatch = categoryName.toLowerCase().includes(kw)
680-
return memoMatch || categoryMatch
691+
return memoMatch || counterpartyMatch || categoryMatch
681692
})
682693
}
683694

@@ -1716,6 +1727,13 @@ function TransactionsPage() {
17161727
</div>
17171728
) : null}
17181729

1730+
{tx.counterparty?.trim() ? (
1731+
<div className="flex items-baseline justify-between">
1732+
<span className="text-sm text-muted-foreground">거래처</span>
1733+
<span className="text-sm">{tx.counterparty}</span>
1734+
</div>
1735+
) : null}
1736+
17191737
{tx.createdAt ? (
17201738
<div className="flex items-baseline justify-between">
17211739
<span className="text-sm text-muted-foreground">등록일시</span>
@@ -1789,7 +1807,7 @@ function TransactionsPage() {
17891807
<button
17901808
key={item}
17911809
type="button"
1792-
onClick={() => setTab(item)}
1810+
onClick={() => { setTab(item); if (item !== 'EXPENSE') setCounterparty('') }}
17931811
aria-pressed={tab === item}
17941812
className={`rounded-md px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${tab === item ? 'bg-primary text-primary-foreground' : 'border'}`}
17951813
>
@@ -1922,6 +1940,18 @@ function TransactionsPage() {
19221940
) : null}
19231941
</div>
19241942

1943+
{tab === 'EXPENSE' ? (
1944+
<input
1945+
value={counterparty}
1946+
onChange={(e) => setCounterparty(e.target.value)}
1947+
placeholder="거래처 (선택)"
1948+
className="h-11 w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1949+
aria-label="거래처"
1950+
autoComplete="off"
1951+
maxLength={100}
1952+
/>
1953+
) : null}
1954+
19251955
<label className="flex items-center gap-2 text-sm">
19261956
<input type="checkbox" checked={isFixed} onChange={(e) => setIsFixed(e.target.checked)} />
19271957
고정 여부
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "transactions" ADD COLUMN "counterparty" varchar(100);

0 commit comments

Comments
 (0)