diff --git a/src/api/accountApi.js b/src/api/accountApi.js index a461886..b25b5b4 100644 --- a/src/api/accountApi.js +++ b/src/api/accountApi.js @@ -31,6 +31,10 @@ const realApi = { parseText({ title, text } = {}) { return http.post('/account/entries/parse', { title, text }) }, + // 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움 + fxRate(from, to = 'KRW') { + return http.get('/fx/rate', { params: { from, to } }) + }, // 자주 쓰는 내역(빠른 등록 템플릿) quickEntries() { diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 85bd044..901ed30 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -84,7 +84,42 @@ function resetFilters() { // 추가/수정 모달 const formOpen = ref(false) 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 repayTargetIsCard = computed(() => { @@ -452,7 +487,7 @@ function todayStr() { function openCreate() { editId.value = null 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 = [] syncCategoryMajor() cancelAddCategory() @@ -481,6 +516,9 @@ function openEdit(e) { interest: null, annualFee: null, 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 매핑 (현재 태그 목록 기준) const nameToId = {} @@ -559,6 +597,7 @@ async function submit() { } } submitting.value = true + const foreign = isForeign.value const payload = { entryDate: form.entryDate, type: form.type, @@ -569,6 +608,10 @@ async function submit() { toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null, installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null, tagIds: selectedTagIds.value, + // 외화 결제: amount 는 환산 원화, 원본은 별도 보존 + currency: foreign ? form.currency : null, + originalAmount: foreign ? Number(form.foreignAmount) : null, + exchangeRate: foreign ? Number(form.rate) : null, } try { if (editId.value) { @@ -947,7 +990,8 @@ onMounted(async () => { -
+
+ {{ e.currency }} {{ Number(e.originalAmount).toLocaleString('ko-KR') }} @{{ Number(e.exchangeRate).toLocaleString('ko-KR') }} {{ e.installmentMonths }}개월 할부 · 월 {{ won(Math.round(e.amount / e.installmentMonths)) }} {{ e.memo }} {{ t }} @@ -1095,7 +1139,23 @@ onMounted(async () => {
- + +
+ + + = ₩{{ (convertedKrw || 0).toLocaleString('ko-KR') }} +
@@ -1853,6 +1913,59 @@ button.primary { color: hsla(160, 100%, 37%, 1); 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 { margin-right: 0.35rem; font-size: 0.75rem;