Compare commits

...

3 Commits

Author SHA1 Message Date
ByungCheol 1b53af525e feat(account): 대출 실행금액 필드 추가 + 상환방식별 자동계산 분기
CI / build (push) Failing after 14m30s
- loan_amount(대출 실행 금액) 컬럼 추가(DB/도메인/DTO/mapper)
- 계좌 폼: 대출 실행 금액 입력 / 기록 시작 시 잔액(기존 openingBalance) 레이블 분리
- 계좌 카드: 실행금액·금리·상환방식 표시
- 상환 자동계산 3방식 분기:
  - 원리금균등: 납입금액 입력 → 이자(잔액×월이율) / 원금(납입-이자) 분리
  - 원금균등: 실행금액÷기간=월원금, 잔액×월이율=이자 자동계산
  - 만기일시: 잔액×월이율=이자만 자동계산, 원금 0
- 자동계산 후 이자·원금 수동 조정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:31:50 +09:00
ByungCheol f8e6c6b0ee feat(account): 대출 계좌 금리/상환방식 저장 + 상환 시 납입금액 자동분리
CI / build (push) Failing after 14m10s
- wallet 테이블에 loan_rate/loan_method/loan_months/loan_start 컬럼 추가(ALTER TABLE)
- 대출 계좌 폼에 연이자율(%), 상환방식(원리금균등/원금균등/만기일시), 기간, 시작일 입력 필드 추가
- 계좌 목록에 금리·상환방식 표시
- 상환 폼: 금리 설정된 대출 계좌 선택 시 납입금액 입력 → 이자/원금 자동계산
  - 원리금균등/원금균등: 잔액 × 연이율/12 = 이자, 납입액-이자 = 원금
  - 만기일시상환: 납입액 전체 이자로 처리
  - 자동계산 후 수동 조정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:02:10 +09:00
ByungCheol e1c077dccd fix(account): 소분류 표시 조건 — template 인라인으로 reactivity 보장
CI / build (push) Failing after 14m42s
rowContainsSelected 함수 대신 template에서 row.some(m => m.id === selectedMajorObj.id) 직접 평가.
함수 호출 시 의존성 추적 불안정 문제 해소.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:41:43 +09:00
2 changed files with 155 additions and 9 deletions
+96 -6
View File
@@ -127,6 +127,56 @@ const repayTargetIsCard = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId) const w = wallets.value.find((x) => x.id === form.toWalletId)
return !!w && w.type === 'CARD' return !!w && w.type === 'CARD'
}) })
// 금리가 설정된 대출 계좌를 선택한 경우 자동계산 활성
const repayTargetLoanWallet = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return w?.type === 'LOAN' && w.loanRate ? w : null
})
// 납입금액 입력용 (원리금균등만 사용)
const loanPaymentAmount = ref(null)
function monthlyInterestOf(loan) {
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
}
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
const loanAutoResult = computed(() => {
const loan = repayTargetLoanWallet.value
if (!loan) return null
const interest = monthlyInterestOf(loan)
if (loan.loanMethod === 'BULLET') {
return { interest, principal: 0, total: interest, needsInput: false }
}
if (loan.loanMethod === 'EQUAL_PRINCIPAL' && loan.loanAmount && loan.loanMonths) {
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
return { interest, principal, total: principal + interest, needsInput: false }
}
return { interest: null, principal: null, total: null, needsInput: true }
})
watch(loanAutoResult, (r) => {
if (r && !r.needsInput) {
form.interest = r.interest
form.principal = r.principal
loanPaymentAmount.value = r.total
}
})
function calcLoanRepayment(payment) {
const loan = repayTargetLoanWallet.value
if (!loan || !payment || payment <= 0) return
const interest = monthlyInterestOf(loan)
form.interest = Math.min(interest, payment)
form.principal = Math.max(0, payment - form.interest)
}
watch(loanPaymentAmount, (val) => {
if (loanAutoResult.value?.needsInput && val) calcLoanRepayment(Number(val))
})
watch(() => form.toWalletId, () => {
loanPaymentAmount.value = null
form.principal = null
form.interest = null
})
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값) // 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD') const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => { const installmentMonthly = computed(() => {
@@ -293,11 +343,7 @@ const majorRows = computed(() => {
const selectedMajorObj = computed(() => const selectedMajorObj = computed(() =>
majorOptionsData.value.find((m) => m.name === categoryMajor.value) ?? null majorOptionsData.value.find((m) => m.name === categoryMajor.value) ?? null
) )
// 해당 행에 선택된 대분류가 포함되는지 확인
function rowContainsSelected(row) {
if (!selectedMajorObj.value) return false
return row.some((m) => m.id === selectedMajorObj.value.id)
}
// 특정 대분류의 소분류 목록 // 특정 대분류의 소분류 목록
function subsByMajor(majorId) { function subsByMajor(majorId) {
return categories.value.filter((c) => c.parentId === majorId) return categories.value.filter((c) => c.parentId === majorId)
@@ -1139,8 +1185,34 @@ onMounted(async () => {
</option> </option>
</select> </select>
</label> </label>
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<template v-if="repayTargetLoanWallet">
<!-- 원리금균등: 납입금액 직접 입력 -->
<template v-if="loanAutoResult?.needsInput">
<label>납입금액(원리금균등)
<input v-model.number="loanPaymentAmount" type="number" min="0"
placeholder="이번 달 납입할 총금액" :disabled="submitting" />
</label>
</template>
<!-- 원금균등·만기일시: 자동계산 결과 표시 -->
<template v-else>
<div class="loan-breakdown">
<span class="bd-label">{{ repayTargetLoanWallet.loanMethod === 'BULLET' ? '만기일시' : '원금균등' }} 자동계산</span>
<span>납입 <b>{{ (loanAutoResult?.total || 0).toLocaleString('ko-KR') }}</b></span>
</div>
</template>
<!-- 계산 결과 표시 + 수동 조정 -->
<div v-if="(form.interest ?? null) !== null || (form.principal ?? null) !== null" class="loan-breakdown">
<span>이자 <b>{{ (form.interest || 0).toLocaleString('ko-KR') }}</b></span>
<span>원금 <b>{{ (form.principal || 0).toLocaleString('ko-KR') }}</b></span>
</div>
<label>이자(조정)<input v-model.number="form.interest" type="number" min="0" :disabled="submitting" /></label>
<label>원금(조정)<input v-model.number="form.principal" type="number" min="0" :disabled="submitting" /></label>
</template>
<template v-else>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label> <label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label> <label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
</template>
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label> <label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
</template> </template>
@@ -1157,7 +1229,7 @@ onMounted(async () => {
@click="onMajorClick(m)" @click="onMajorClick(m)"
>{{ m.name }}</button> >{{ m.name }}</button>
</div> </div>
<div v-if="rowContainsSelected(row) && selectedMajorObj && subsByMajor(selectedMajorObj.id).length" class="acc-sub-grid"> <div v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id) && subsByMajor(selectedMajorObj.id).length" class="acc-sub-grid">
<button <button
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id" v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
type="button" class="acc-chip sub" type="button" class="acc-chip sub"
@@ -1977,6 +2049,24 @@ button.primary {
.acc-chip.add-chip.sub-add { .acc-chip.add-chip.sub-add {
flex: 0 0 auto; flex: 0 0 auto;
} }
.loan-breakdown {
display: flex;
gap: 1rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
background: var(--color-background-soft);
font-size: 0.85rem;
color: var(--color-text);
}
.loan-breakdown b {
color: hsla(160, 100%, 37%, 1);
}
.loan-breakdown .bd-label {
width: 100%;
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.1rem;
}
.tag-field { .tag-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+57 -1
View File
@@ -33,6 +33,12 @@ const form = reactive({
openingBalance: 0, openingBalance: 0,
openingDate: '', openingDate: '',
currentValue: null, currentValue: null,
// 대출(LOAN) 전용
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
}) })
const submitting = ref(false) const submitting = ref(false)
const formError = ref(null) const formError = ref(null)
@@ -196,7 +202,7 @@ function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관' return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
} }
function openingLabel(t) { function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)' return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
} }
// 투자 수익률(%) — 투입원금 대비 평가손익 // 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) { function returnPct(w) {
@@ -229,6 +235,11 @@ function openCreate() {
openingBalance: 0, openingBalance: 0,
openingDate: '', openingDate: '',
currentValue: null, currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
}) })
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
@@ -245,6 +256,11 @@ function openEdit(w) {
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0, openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '', openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null, currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
}) })
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
@@ -258,6 +274,7 @@ async function submit() {
} }
submitting.value = true submitting.value = true
const magnitude = Number(form.openingBalance) || 0 const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = { const payload = {
type: form.type, type: form.type,
name: form.name.trim(), name: form.name.trim(),
@@ -271,6 +288,11 @@ async function submit() {
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue) ? Number(form.currentValue)
: null, : null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
} }
try { try {
if (editId.value) await accountApi.updateWallet(editId.value, payload) if (editId.value) await accountApi.updateWallet(editId.value, payload)
@@ -297,6 +319,9 @@ async function remove(w) {
function cardTypeLabel(v) { function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : '' return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
} }
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
onMounted(async () => { onMounted(async () => {
await load() await load()
@@ -362,6 +387,10 @@ onBeforeUnmount(() => sortable?.destroy())
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button> >{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template> </template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template> <template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span>
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
</template>
</span> </span>
</div> </div>
<div class="balance-wrap"> <div class="balance-wrap">
@@ -458,6 +487,33 @@ onBeforeUnmount(() => sortable?.destroy())
</template> </template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label> <label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
placeholder="예: 5.25" :disabled="submitting" />
</label>
<label>상환방식
<select v-model="form.loanMethod" :disabled="submitting">
<option value="">(선택 )</option>
<option value="EQUAL_PAYMENT">원리금균등상환</option>
<option value="EQUAL_PRINCIPAL">원금균등상환</option>
<option value="BULLET">만기일시상환</option>
</select>
</label>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p> <p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons"> <div class="buttons">