Skip to content

Commit 26c42cf

Browse files
authored
[3-9] 잔고 현황 할부 연동 (#112)
* feat: 잔고 현황 할부 연동 및 거래 상세 원본 링크 추가 * fix: PR 리뷰 반영 (할부 조회 실패 격리 및 SPA 라우팅)
1 parent ef0e345 commit 26c42cf

File tree

3 files changed

+166
-18
lines changed

3 files changed

+166
-18
lines changed

apps/web/src/routes/reports.tsx

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
77
import {
88
ArrowDown,
99
ArrowUp,
10-
BarChart2,
1110
Building2,
1211
Check,
1312
ChevronLeft,
@@ -131,6 +130,22 @@ type CashFlow = {
131130
total: number
132131
}
133132

133+
type InstallmentProgress = {
134+
processedMonths: number
135+
totalMonths: number
136+
progressRate: number
137+
}
138+
139+
type InstallmentSummary = {
140+
id: number
141+
accountId: number
142+
totalAmount: number
143+
totalMonths: number
144+
monthlyAmount: number
145+
memo: string | null
146+
progress: InstallmentProgress
147+
}
148+
134149
type BalanceReport = {
135150
summary: {
136151
totalAssets: number
@@ -311,7 +326,15 @@ function NetAssetCard({ summary }: { summary: BalanceReport['summary'] }) {
311326
)
312327
}
313328

314-
function AccountGroupSection({ group, type }: { group: AccountGroup; type: 'asset' | 'liability' }) {
329+
function AccountGroupSection({
330+
group,
331+
type,
332+
installmentsByAccount,
333+
}: {
334+
group: AccountGroup
335+
type: 'asset' | 'liability'
336+
installmentsByAccount?: Map<number, InstallmentSummary[]>
337+
}) {
315338
const [expanded, setExpanded] = useState(true)
316339
const isAsset = type === 'asset'
317340
const panelId = `group-panel-${type}-${group.major}`
@@ -331,14 +354,33 @@ function AccountGroupSection({ group, type }: { group: AccountGroup; type: 'asse
331354
</span>
332355
</button>
333356
<div id={panelId} hidden={!expanded} className="divide-y">
334-
{group.accounts.map((account) => (
335-
<div key={account.id} className="flex items-center justify-between px-4 py-2.5">
336-
<span className="text-sm text-foreground">{account.name}</span>
337-
<span className={cn('text-sm font-medium', isAsset ? 'text-foreground' : 'text-red-500')}>
338-
{formatAmount(account.balance)}
339-
</span>
340-
</div>
341-
))}
357+
{group.accounts.map((account) => {
358+
const linkedInstallments = isAsset ? [] : (installmentsByAccount?.get(account.id) ?? [])
359+
360+
return (
361+
<div key={account.id} className="flex items-start justify-between gap-3 px-4 py-2.5">
362+
<div className="flex min-w-0 flex-col gap-1">
363+
<span className="text-sm text-foreground">{account.name}</span>
364+
{linkedInstallments.map((installment) => {
365+
const remainingMonths = Math.max(
366+
installment.totalMonths - installment.progress.processedMonths,
367+
0,
368+
)
369+
const installmentName = installment.memo?.trim() || `할부 #${installment.id}`
370+
371+
return (
372+
<span key={installment.id} className="text-xs text-muted-foreground">
373+
{installmentName} · 잔여 {remainingMonths}개월 · 월 {formatAmount(installment.monthlyAmount)}
374+
</span>
375+
)
376+
})}
377+
</div>
378+
<span className={cn('text-sm font-medium', isAsset ? 'text-foreground' : 'text-red-500')}>
379+
{formatAmount(account.balance)}
380+
</span>
381+
</div>
382+
)
383+
})}
342384
</div>
343385
</div>
344386
)
@@ -389,6 +431,7 @@ function CashFlowCard({ cashFlow }: { cashFlow: CashFlow }) {
389431

390432
function BalanceTab({ ledgerId }: { ledgerId: number }) {
391433
const [report, setReport] = useState<BalanceReport | null>(null)
434+
const [installmentsByAccount, setInstallmentsByAccount] = useState<Map<number, InstallmentSummary[]>>(new Map())
392435
const [loading, setLoading] = useState(true)
393436
const [error, setError] = useState<string | null>(null)
394437

@@ -398,15 +441,15 @@ function BalanceTab({ ledgerId }: { ledgerId: number }) {
398441
setError(null)
399442

400443
apiFetch(`/api/ledgers/${ledgerId}/reports/balance`)
401-
.then(async (res) => {
444+
.then(async (balanceRes) => {
402445
let json: unknown = null
403446
try {
404-
json = await res.json()
447+
json = await balanceRes.json()
405448
} catch {
406449
// 응답 본문이 JSON이 아닌 경우 json은 null로 유지
407450
}
408451

409-
if (!res.ok) {
452+
if (!balanceRes.ok) {
410453
let message = '잔고 데이터를 불러오지 못했습니다.'
411454
if (json && typeof json === 'object') {
412455
const anyJson = json as { error?: { message?: unknown }; message?: unknown }
@@ -423,7 +466,55 @@ function BalanceTab({ ledgerId }: { ledgerId: number }) {
423466
json && typeof json === 'object' && 'data' in json
424467
? (json as { data: BalanceReport }).data
425468
: (json as BalanceReport)
426-
if (!cancelled) setReport(data)
469+
if (!cancelled) {
470+
setReport(data)
471+
}
472+
473+
let installmentsRes: Response | null = null
474+
try {
475+
installmentsRes = await apiFetch(`/api/ledgers/${ledgerId}/installments`)
476+
} catch {
477+
installmentsRes = null
478+
}
479+
480+
if (!installmentsRes || !installmentsRes.ok) {
481+
if (!cancelled) {
482+
setInstallmentsByAccount(new Map())
483+
}
484+
return
485+
}
486+
487+
let installmentsPayload: { data?: InstallmentSummary[] } | InstallmentSummary[] = []
488+
try {
489+
installmentsPayload = (await installmentsRes.json()) as
490+
| { data?: InstallmentSummary[] }
491+
| InstallmentSummary[]
492+
} catch {
493+
installmentsPayload = []
494+
}
495+
const installments = Array.isArray(installmentsPayload)
496+
? installmentsPayload
497+
: (installmentsPayload.data ?? [])
498+
const nextInstallmentsByAccount = new Map<number, InstallmentSummary[]>()
499+
500+
for (const installment of installments) {
501+
const remainingMonths = Math.max(
502+
installment.totalMonths - installment.progress.processedMonths,
503+
0,
504+
)
505+
if (remainingMonths <= 0) continue
506+
507+
const existing = nextInstallmentsByAccount.get(installment.accountId)
508+
if (existing) {
509+
existing.push(installment)
510+
} else {
511+
nextInstallmentsByAccount.set(installment.accountId, [installment])
512+
}
513+
}
514+
515+
if (!cancelled) {
516+
setInstallmentsByAccount(nextInstallmentsByAccount)
517+
}
427518
})
428519
.catch((err: Error) => {
429520
if (!cancelled) setError(err.message)
@@ -497,7 +588,12 @@ function BalanceTab({ ledgerId }: { ledgerId: number }) {
497588
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-0.5">부채</h2>
498589
<div className="space-y-2">
499590
{report.liabilityGroups.map((group) => (
500-
<AccountGroupSection key={group.major} group={group} type="liability" />
591+
<AccountGroupSection
592+
key={group.major}
593+
group={group}
594+
type="liability"
595+
installmentsByAccount={installmentsByAccount}
596+
/>
501597
))}
502598
</div>
503599
</section>

apps/web/src/routes/transactions.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EmptyState } from '@repo/ui/components/ui/empty-state'
22
import { Button } from '@repo/ui/components/ui/button'
33
import { MonthPickerSheet } from '@repo/ui/components/ui/month-picker-sheet'
44
import { showErrorSnackbar, showSuccessSnackbar, showUndoSnackbar } from '@repo/ui/components/ui/snackbar'
5-
import { createFileRoute, redirect } from '@tanstack/react-router'
5+
import { Link, createFileRoute, redirect } from '@tanstack/react-router'
66
import {
77
CalendarDays,
88
ChevronLeft,
@@ -59,6 +59,19 @@ type Schedule = {
5959
templateAccountInId: number | null
6060
templateAccountOutId: number | null
6161
}
62+
type Installment = {
63+
id: number
64+
accountId: number
65+
totalAmount: number
66+
totalMonths: number
67+
monthlyAmount: number
68+
memo: string | null
69+
progress: {
70+
processedMonths: number
71+
totalMonths: number
72+
progressRate: number
73+
}
74+
}
6275
type ResolvedSchedule = Schedule & {
6376
resolvedDate: string
6477
}
@@ -180,6 +193,7 @@ function TransactionsPage() {
180193
const [categories, setCategories] = useState<Category[]>([])
181194
const [accounts, setAccounts] = useState<Account[]>([])
182195
const [schedules, setSchedules] = useState<Schedule[]>([])
196+
const [installments, setInstallments] = useState<Installment[]>([])
183197
const [recentCategories, setRecentCategories] = useState<Category[]>([])
184198

185199
const goPrevMonth = () => {
@@ -239,6 +253,23 @@ function TransactionsPage() {
239253
const payload = (await schedulesRes.json()) as { data?: Schedule[] } | Schedule[]
240254
setSchedules(Array.isArray(payload) ? payload : (payload.data ?? []))
241255
}
256+
257+
try {
258+
const installmentsRes = await apiFetch(`/api/ledgers/${ledgerId}/installments`)
259+
if (!installmentsRes.ok) {
260+
setInstallments([])
261+
return
262+
}
263+
264+
try {
265+
const payload = (await installmentsRes.json()) as { data?: Installment[] } | Installment[]
266+
setInstallments(Array.isArray(payload) ? payload : (payload.data ?? []))
267+
} catch {
268+
setInstallments([])
269+
}
270+
} catch {
271+
setInstallments([])
272+
}
242273
}, [ledgerId])
243274

244275
const fetchTransactions = useCallback(async () => {
@@ -520,6 +551,7 @@ function TransactionsPage() {
520551
const monthLabel = `${selectedYear}${selectedMonth}월`
521552
const categoryOptions = categories.filter((item) => item.type === tab)
522553
const accountNameById = useMemo(() => new Map(accounts.map((a) => [a.id, a.name])), [accounts])
554+
const installmentById = useMemo(() => new Map(installments.map((item) => [item.id, item])), [installments])
523555
const categoryNameById = useMemo(
524556
() => new Map(categories.map((c) => [c.id, `${c.major} · ${c.minor}`])),
525557
[categories],
@@ -1633,6 +1665,10 @@ function TransactionsPage() {
16331665
const categoryName = tx.categoryId
16341666
? (categoryNameById.get(tx.categoryId) ?? (hasCategoriesLoaded ? '(삭제됨)' : '(불러오는 중)'))
16351667
: null
1668+
const linkedInstallment = tx.installmentId ? (installmentById.get(tx.installmentId) ?? null) : null
1669+
const remainingInstallmentMonths = linkedInstallment
1670+
? Math.max(linkedInstallment.totalMonths - linkedInstallment.progress.processedMonths, 0)
1671+
: null
16361672

16371673
return (
16381674
<div
@@ -1723,7 +1759,22 @@ function TransactionsPage() {
17231759

17241760
{tx.installmentId ? (
17251761
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
1726-
할부 연결 거래입니다. 개별 삭제는 불가하며, 설정 &gt; 할부에서 관리할 수 있습니다.
1762+
<p className="font-medium">할부 연결 거래입니다.</p>
1763+
{linkedInstallment ? (
1764+
<Link
1765+
to="/settings"
1766+
onClick={() => setDetailTransaction(null)}
1767+
className="mt-1 inline-flex text-xs font-medium text-amber-950 underline underline-offset-2"
1768+
>
1769+
원본 할부 정보: 총액 {Number(linkedInstallment.totalAmount).toLocaleString()}원 · 잔여{' '}
1770+
{remainingInstallmentMonths}개월
1771+
</Link>
1772+
) : (
1773+
<span className="mt-1 inline-flex text-xs">
1774+
원본 할부 정보를 불러올 수 없습니다.
1775+
</span>
1776+
)}
1777+
<p className="mt-1">개별 삭제는 불가하며, 설정 &gt; 할부에서 관리할 수 있습니다.</p>
17271778
</div>
17281779
) : null}
17291780
</div>

memories/task.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,9 +480,10 @@
480480
- **의존성:** 1-8, 3-7
481481

482482
### [3-9] 잔고 현황 할부 연동
483-
- **상태:** 대기
483+
- **상태:** 완료
484484
- **범위:** `apps/web`
485485
- **설명:** 잔고 현황 부채 목록에서 할부 연결된 계좌에 잔여 개월 + 월 상환액 표시. 거래 상세에서 할부 연결 거래에 원본 할부 정보 링크(총액, 잔여 개월).
486+
- **진행 메모 (2026-03-31):** `apps/web/src/routes/reports.tsx`에서 잔고 탭 조회 시 할부 API를 함께 호출해 부채 계좌별 활성 할부의 `잔여 개월 · 월 상환액`을 노출하도록 반영. `apps/web/src/routes/transactions.tsx` 거래 상세에서 할부 연결 거래에 원본 할부 정보 링크(총액, 잔여 개월)를 추가.
486487
- **참조:** screen-design §6-10, §6-7
487488
- **완료 조건:**
488489
- 부채 목록에 할부 잔여 개월·월 상환액 표시

0 commit comments

Comments
 (0)