- 입력폼: 통화 드롭다운(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:
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user