feat: 외화 입/지출 입력 — 통화 선택 + 환율 자동조회 → 원화 환산
CI / build (push) Failing after 15m36s

- 입력폼: 통화 드롭다운(KRW 기본 + 15개), 외화면 외화금액+환율 입력
  → 환산 원화 자동 계산(amount). 환율은 /fx/rate 자동조회('↻ 자동') + 수정 가능
- 저장: 외화면 currency/originalAmount/exchangeRate 전송, amount=환산 원화
- 목록 상세에 'USD 10.00 @1,350' 외화 뱃지 표시
- accountApi.fxRate 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-30 21:37:24 +09:00
parent caa591a16d
commit dab6dc3411
2 changed files with 121 additions and 4 deletions
+4
View File
@@ -31,6 +31,10 @@ const realApi = {
parseText({ title, text } = {}) { parseText({ title, text } = {}) {
return http.post('/account/entries/parse', { title, text }) return http.post('/account/entries/parse', { title, text })
}, },
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') {
return http.get('/fx/rate', { params: { from, to } })
},
// 자주 쓰는 내역(빠른 등록 템플릿) // 자주 쓰는 내역(빠른 등록 템플릿)
quickEntries() { quickEntries() {
+117 -4
View File
@@ -84,7 +84,42 @@ function resetFilters() {
// 추가/수정 모달 // 추가/수정 모달
const formOpen = ref(false) const formOpen = ref(false)
const editId = ref(null) const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' }) const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
// ===== 외화 결제 =====
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
const isForeign = computed(() => form.currency && form.currency !== 'KRW')
// JPY 등은 원본이 정수 위주라 100단위 표기가 흔하지만, 환율은 1단위 기준으로 통일
const convertedKrw = computed(() =>
isForeign.value ? Math.round((Number(form.foreignAmount) || 0) * (Number(form.rate) || 0)) : null,
)
// 외화면 환산 원화를 amount 에 반영(저장·검증·표시는 원화 기준)
watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
if (isForeign.value) form.amount = convertedKrw.value
})
const fxLoading = ref(false)
async function fetchRate() {
if (!isForeign.value || fxLoading.value) return
fxLoading.value = true
try {
const res = await accountApi.fxRate(form.currency, 'KRW')
if (res?.rate != null) form.rate = Number(res.rate)
else formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
} catch {
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
} finally {
fxLoading.value = false
}
}
function onCurrencyChange() {
if (isForeign.value) {
if (form.foreignAmount == null && form.amount != null) form.foreignAmount = form.amount // 원화→외화 전환 시 초기값
fetchRate()
} else {
form.foreignAmount = null
form.rate = null
}
}
const isRepayment = computed(() => form.type === 'REPAYMENT') const isRepayment = computed(() => form.type === 'REPAYMENT')
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음) // 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
const repayTargetIsCard = computed(() => { const repayTargetIsCard = computed(() => {
@@ -452,7 +487,7 @@ function todayStr() {
function openCreate() { function openCreate() {
editId.value = null editId.value = null
editingPending.value = false editingPending.value = false
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' }) Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
selectedTagIds.value = [] selectedTagIds.value = []
syncCategoryMajor() syncCategoryMajor()
cancelAddCategory() cancelAddCategory()
@@ -481,6 +516,9 @@ function openEdit(e) {
interest: null, interest: null,
annualFee: null, annualFee: null,
installmentMonths: e.installmentMonths || '', installmentMonths: e.installmentMonths || '',
currency: e.currency && e.currency !== 'KRW' ? e.currency : 'KRW',
foreignAmount: e.currency && e.currency !== 'KRW' ? Number(e.originalAmount) : null,
rate: e.currency && e.currency !== 'KRW' ? Number(e.exchangeRate) : null,
}) })
// 태그 이름 → id 매핑 (현재 태그 목록 기준) // 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {} const nameToId = {}
@@ -559,6 +597,7 @@ async function submit() {
} }
} }
submitting.value = true submitting.value = true
const foreign = isForeign.value
const payload = { const payload = {
entryDate: form.entryDate, entryDate: form.entryDate,
type: form.type, type: form.type,
@@ -569,6 +608,10 @@ async function submit() {
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null, toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null, installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
tagIds: selectedTagIds.value, tagIds: selectedTagIds.value,
// 외화 결제: amount 는 환산 원화, 원본은 별도 보존
currency: foreign ? form.currency : null,
originalAmount: foreign ? Number(form.foreignAmount) : null,
exchangeRate: foreign ? Number(form.rate) : null,
} }
try { try {
if (editId.value) { if (editId.value) {
@@ -947,7 +990,8 @@ onMounted(async () => {
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span> </span>
</div> </div>
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2"> <div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length) || (e.currency && e.currency !== 'KRW')" class="ei-line2">
<span v-if="e.currency && e.currency !== 'KRW'" class="ei-fx">{{ e.currency }} {{ Number(e.originalAmount).toLocaleString('ko-KR') }} @{{ Number(e.exchangeRate).toLocaleString('ko-KR') }}</span>
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span> <span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span> <span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span> <span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
@@ -1095,7 +1139,23 @@ onMounted(async () => {
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button> <button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
</div> </div>
</label> </label>
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label> <label v-if="!isRepayment" class="amount-label">
금액
<div class="amount-row">
<select v-model="form.currency" class="cur-sel" :disabled="submitting" @change="onCurrencyChange">
<option v-for="c in CURRENCIES" :key="c" :value="c">{{ c }}</option>
</select>
<input v-if="!isForeign" v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" />
<input v-else v-model.number="form.foreignAmount" type="number" min="0" step="0.01" :placeholder="form.currency + ' 금액'" :disabled="submitting" />
</div>
</label>
<div v-if="!isRepayment && isForeign" class="fx-row">
<label class="fx-rate">환율(1 {{ form.currency }})
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
</label>
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button>
<span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
</div>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label> <label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment && isPremium" class="tag-field"> <div v-if="!isRepayment && isPremium" class="tag-field">
@@ -1853,6 +1913,59 @@ button.primary {
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
white-space: nowrap; white-space: nowrap;
} }
/* 외화 표기(목록 상세) */
.ei-fx {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(40, 90%, 50%, 0.6);
border-radius: 3px;
color: #b8860b;
white-space: nowrap;
}
/* 입력폼 금액/환율 */
.amount-row {
display: flex;
gap: 0.4rem;
}
.cur-sel {
flex: none;
width: 5rem;
}
.amount-row input {
flex: 1;
min-width: 0;
}
.fx-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: -0.3rem 0 0.2rem;
}
.fx-rate {
display: flex;
flex-direction: column;
font-size: 0.8rem;
}
.fx-rate input {
width: 7rem;
}
.fx-auto {
align-self: flex-end;
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.fx-krw {
align-self: flex-end;
font-weight: 700;
color: #b8860b;
font-size: 0.9rem;
}
.row-wallet { .row-wallet {
margin-right: 0.35rem; margin-right: 0.35rem;
font-size: 0.75rem; font-size: 0.75rem;