Files
sb-front/src/views/account/AccountView.vue
T
sb 3429ddc235
Deploy / deploy (push) Failing after 12m42s
fix(ui): 내역 모달 min-width:0 — flex 아이템이 내용물만큼 커져 넘치던 근본 원인
backdrop(flex)의 모달 아이템 기본 min-width:auto 가 넓은 그리드만큼 모달을 뷰포트보다
넓혔음. 모달 min-width:0 으로 고정(+칩 min-width:0, overflow-x:hidden 조합)으로 가로 오버플로우 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:03:48 +09:00

2446 lines
79 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import { appLock } from '@/composables/appLock'
import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import ChipSelect from '@/components/ui/ChipSelect.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { cardNotif } from '@/native/cardNotif'
import { CARD_NOTIF_ENABLED } from '@/config/features'
import { Capacitor } from '@capacitor/core'
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
const dialog = useDialog()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const entries = ref([])
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const loading = ref(false)
const error = ref(null)
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
// 카드 결제 알림 자동인식 권한 (네이티브 전용)
const notifNative = CARD_NOTIF_ENABLED && cardNotif.isNative()
const notifEnabled = ref(true) // 미허용일 때만 배너 노출
async function checkNotifPermission() {
if (notifNative) notifEnabled.value = await cardNotif.isEnabled()
}
function openNotifSettings() {
cardNotif.openSettings()
}
async function loadPendingCount() {
try {
pendingCount.value = (await accountApi.pendingCount()).count || 0
} catch {
pendingCount.value = 0
}
}
// 미확인 내역 즉시 확정(분류 미지정으로 수락)
async function confirmRow(e) {
try {
await accountApi.confirmEntry(e.id, {})
e.pending = false // UI 즉시 반영(목록 재로딩 전)
await load()
await loadPendingCount()
} catch (err) {
const st = err.response?.status
alert(err.response?.data?.message || `확인 처리에 실패했습니다.${st ? ' (HTTP ' + st + ')' : ' (네트워크 오류)'}`)
}
}
// 검색·필터
const filterOpen = ref(false)
const filters = reactive({ keyword: '', type: '', category: '', walletId: '', tagId: '' })
const hasFilter = computed(() => !!(filters.keyword || filters.type || filters.category || filters.walletId || filters.tagId))
function filterParams() {
const p = {}
if (filters.keyword) p.keyword = filters.keyword.trim()
if (filters.type) p.type = filters.type
if (filters.category) p.category = filters.category
if (filters.walletId) p.walletId = filters.walletId
if (filters.tagId) p.tagId = filters.tagId
return p
}
function applyFilters() {
load()
}
function resetFilters() {
filters.keyword = ''
filters.type = ''
filters.category = ''
filters.walletId = ''
filters.tagId = ''
load()
}
// 추가/수정 모달
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: '', 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)
const rateUpdatedAt = ref(null) // 환율 마지막 조회 시각(ms)
const tickNow = ref(Date.now())
let _fxTick = null
onMounted(() => { _fxTick = setInterval(() => { tickNow.value = Date.now() }, 30_000) })
onUnmounted(() => clearInterval(_fxTick))
const rateAgeLabel = computed(() => {
if (!rateUpdatedAt.value || !isForeign.value) return null
const diff = Math.floor((tickNow.value - rateUpdatedAt.value) / 1000)
if (diff < 60) return '방금 업데이트'
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
return `${Math.floor(diff / 86400)}일 전`
})
async function fetchRate() {
if (!isForeign.value || fxLoading.value) return
fxLoading.value = true
try {
const res = await accountApi.fxRate(form.currency, 'KRW')
// 환율은 소수점 4자리로 통일(입력폼 step·저장 정밀도 일치)
if (res?.rate != null) {
form.rate = Math.round(Number(res.rate) * 10000) / 10000
rateUpdatedAt.value = res.updatedAt ? new Date(res.updatedAt).getTime() : Date.now()
} 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
rateUpdatedAt.value = null
}
}
// 계좌/카드 (repayTargetIsCard·repayTargetLoanWallet computed가 참조하므로 먼저 선언)
const wallets = ref([])
const isRepayment = computed(() => form.type === 'REPAYMENT')
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
const repayTargetIsCard = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
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개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => {
const m = Number(form.installmentMonths)
const amt = Number(form.amount)
return m >= 2 && amt > 0 ? Math.round(amt / m) : 0
})
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
const submitting = ref(false)
const formError = ref(null)
const formInfo = ref('') // 모달 내 안내(예: 자주 쓰는 내역 저장됨)
// 영수증 OCR (온디바이스)
const receiptInput = ref(null)
const ocrRunning = ref(false)
const ocrResult = ref(null) // { amount, date, store }
const receiptPickerOpen = ref(false) // 카메라/갤러리 선택 레이어
const webCapture = ref(false) // 웹: 카메라 버튼이면 capture 활성
// 영수증 등록: 커스텀 레이어 팝업(카메라/갤러리 선택)
function pickReceipt() {
ocrResult.value = null
receiptPickerOpen.value = true
}
async function chooseCamera() {
receiptPickerOpen.value = false
if (Capacitor.isNativePlatform()) {
await captureFrom('CAMERA')
} else {
webCapture.value = true
await nextTick()
receiptInput.value?.click()
}
}
async function chooseGallery() {
receiptPickerOpen.value = false
if (Capacitor.isNativePlatform()) {
await captureFrom('PHOTOS')
} else {
webCapture.value = false
await nextTick()
receiptInput.value?.click()
}
}
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
async function captureFrom(sourceKey) {
try {
appLock.suppressLock() // 카메라 복귀 시 앱 잠금 오작동 방지
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
const photo = await Camera.getPhoto({
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
resultType: CameraResultType.DataUrl,
quality: 70,
correctOrientation: true,
})
if (photo?.dataUrl) await runOcr(photo.dataUrl)
} catch {
// 사용자가 취소 → 무시
}
}
async function onReceiptFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 재선택 허용
if (file) await runOcr(file)
}
async function runOcr(image) {
ocrRunning.value = true
ocrResult.value = null
formError.value = null
try {
const blob = await imageToBlob(image)
// 1) AI 비전 우선 — 금액·상호·날짜·분류·계좌를 한 번에 구조화
let ai = null
try {
ai = await accountApi.parseReceiptAi(blob, {
categoryHints: allCategoryNames.value,
walletHints: wallets.value.map((w) => w.name),
})
} catch {
ai = null // AI 실패 → 아래 OCR 폴백
}
if (ai && ai.recognized) {
form.type = ai.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
if (ai.amount) form.amount = Number(ai.amount)
if (ai.merchant && !form.memo) form.memo = ai.merchant
if (ai.date) form.entryDate = ai.date
if (ai.category) form.category = ai.category
let aiCard = null
if (ai.wallet) {
const w = wallets.value.find((x) => x.name === ai.wallet)
if (w) {
form.walletKind = w.type
form.walletId = w.id
aiCard = w.name
}
}
ocrResult.value = { amount: ai.amount, date: ai.date, store: ai.merchant, card: aiCard, category: ai.category, ai: true }
return
}
// 2) 폴백 — 기존 Google Vision OCR + 규칙 파서
const { text } = await accountApi.ocrReceipt(blob)
const r = parseReceiptText(text)
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
if (r.amount) form.amount = r.amount
if (r.date) form.entryDate = r.date
if (r.store && !form.memo) form.memo = r.store
// 카드 결제 영수증이면 등록된 카드 자동 선택 (카드사 매칭)
let cardName = null
if (form.type === 'EXPENSE') {
const card = r.cardIssuer ? matchCardWallet(r.cardIssuer) : null
if (card) {
form.walletKind = 'CARD'
form.walletId = card.id
cardName = card.name
} else if (r.isCard) {
form.walletKind = 'CARD' // 카드결제지만 매칭 실패 → 카드 목록만 좁혀줌
form.walletId = ''
}
}
ocrResult.value = { amount: r.amount, date: r.date, store: r.store, card: cardName }
if (!r.amount && !r.date) {
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
}
} catch (e) {
formError.value = e.response?.data?.message || '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
} finally {
ocrRunning.value = false
}
}
// 계좌/카드
async function loadWallets() {
try {
wallets.value = await accountApi.wallets()
} catch {
wallets.value = []
}
}
// 계좌 종류(콤보) → 해당 종류만 목록에
const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
{ value: 'LOAN', label: '대출' },
{ value: 'MINUS', label: '마이너스' },
{ value: 'INVEST', label: '증권' },
]
// 출금/결제 계좌 종류 뱃지에서는 대출 제외 — 대출은 지출·이체의 '출처'가 아니라 상환 대상
// 출금/결제 계좌 종류: 대출은 항상 제외(상환 대상), 카드는 '지출'(카드결제)일 때만 노출.
// 수입·이체·상환은 카드/대출에서 나가지 않음.
const fromWalletKinds = computed(() =>
WALLET_KINDS.filter((k) => {
if (k.value === 'LOAN') return false
if (k.value === 'CARD') return form.type === 'EXPENSE'
return true
}),
)
// 이체 입금 계좌 종류: 카드·대출은 이체가 아니라 '상환' 대상이므로 제외
const toWalletKinds = computed(() =>
WALLET_KINDS.filter((k) => k.value !== 'CARD' && k.value !== 'LOAN'),
)
const TYPES = [
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
{ value: 'REPAYMENT', label: '상환/납부', cls: 'chip-repay' },
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
// 칩 선택용 옵션(계좌 선택을 select→칩으로)
function walletOpts(list) {
return list.map((w) => ({ value: w.id, label: w.name || w.issuer }))
}
// 출금/결제 계좌: 현금 지출·수입은 '미지정' 선택 허용
const fromWalletOptions = computed(() => {
const opts = walletOpts(walletsOfKind.value)
if (form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE')) {
opts.unshift({ value: '', label: '현금(미지정)' })
}
return opts
})
const toWalletOptions = computed(() => walletOpts(toWalletsOfKind.value))
const repayTargetOptions = computed(() => walletOpts(liabilityWallets.value))
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id)
return w ? w.type : ''
}
// 영수증에서 감지한 카드사명으로 등록된 카드(CARD) 찾기
function matchCardWallet(issuer) {
if (!issuer) return null
return wallets.value.find(
(w) => w.type === 'CARD' && ((w.issuer || '').includes(issuer) || (w.name || '').includes(issuer)),
)
}
function onWalletKindChange() {
form.walletId = ''
}
function onToWalletKindChange() {
form.toWalletId = ''
}
// 분류(카테고리)
const categories = ref([])
async function loadCategories() {
try {
categories.value = await accountApi.categories()
} catch {
categories.value = []
}
}
// 필터용 전체 분류명 (중복 제거)
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
// 구분(수입/지출/이체) 변경 시 분류 초기화 + 새 구분에서 허용 안 되는 계좌 종류면 초기화
function onTypeChange() {
form.category = ''
if (!fromWalletKinds.value.some((k) => k.value === form.walletKind)) {
form.walletKind = ''
form.walletId = ''
}
}
// 태그 (태그 관리에 등록된 태그 선택)
const tagOptions = ref([]) // [{ id, name }]
const selectedTagIds = ref([])
function toggleTag(id) {
const i = selectedTagIds.value.indexOf(id)
if (i >= 0) selectedTagIds.value.splice(i, 1)
else selectedTagIds.value.push(id)
}
async function loadTagOptions() {
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
if (!isPremium.value) {
tagOptions.value = []
return
}
try {
tagOptions.value = await accountApi.tags()
} catch {
tagOptions.value = []
}
}
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function dotClass(type) {
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
}
function amountClass(type) {
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
}
function amountSign(type) {
return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-'
}
// 일별 그룹 + 일 합계 (백엔드가 날짜 내림차순 정렬 → 순서 유지)
const entriesByDay = computed(() => {
const groups = []
const idx = {}
for (const e of entries.value) {
const d = (e.entryDate || '').slice(0, 10)
if (idx[d] === undefined) {
idx[d] = groups.length
groups.push({ date: d, income: 0, expense: 0, items: [] })
}
const g = groups[idx[d]]
g.items.push(e)
if (e.type === 'INCOME') g.income += e.amount
else if (e.type === 'EXPENSE') g.expense += e.amount
}
return groups
})
function dayLabel(d) {
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return d
const wd = ['일', '월', '화', '수', '목', '금', '토'][dt.getDay()]
return `${dt.getMonth() + 1}${dt.getDate()}일 (${wd})`
}
// ===== 날짜별 접기/펴기 (기본 접힘, 오늘만 펼침) =====
const expandedDates = ref(new Set())
function isExpanded(date) {
return expandedDates.value.has(date)
}
function toggleDate(date) {
const s = new Set(expandedDates.value)
s.has(date) ? s.delete(date) : s.add(date)
expandedDates.value = s
}
const allExpanded = computed(() => {
const days = entriesByDay.value
return days.length > 0 && days.every((g) => expandedDates.value.has(g.date))
})
function toggleAll() {
expandedDates.value = allExpanded.value ? new Set() : new Set(entriesByDay.value.map((g) => g.date))
}
// 월이 바뀌면 모두 접고 오늘 날짜만 펼침 (현재 월일 때만 오늘이 목록에 존재)
watch([year, month], () => {
expandedDates.value = new Set([todayStr()])
}, { immediate: true })
async function load() {
loading.value = true
error.value = null
try {
const listParams = { year: year.value, month: month.value, ...filterParams() }
const sumParams = { year: year.value, month: month.value }
const [list, sum] = await Promise.all([accountApi.list(listParams), accountApi.summary(sumParams)])
entries.value = list
summary.value = sum
} catch (e) {
error.value = e.response?.data?.message || '내역을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else {
month.value -= 1
}
load()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else {
month.value += 1
}
load()
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
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: '', currency: 'KRW', foreignAmount: null, rate: null })
rateUpdatedAt.value = null
selectedTagIds.value = []
formError.value = null
formInfo.value = ''
formOpen.value = true
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
}
// 내역 화면에서 바로 빠른입력: 추가 폼을 준비해 열고, 그 위에 빠른입력 모달을 띄운다.
// 분석·채우기 후 빠른입력 모달은 닫히고, 채워진 추가 폼이 남아 확인·저장.
function openQuickInput() {
openCreate()
openPasteModal()
}
function openEdit(e) {
editId.value = e.id
editingPending.value = !!e.pending
Object.assign(form, {
entryDate: e.entryDate,
type: e.type,
category: e.category || '',
amount: e.amount,
memo: e.memo || '',
// 알림 자동인식(pending): 매칭 실패 시 수입(입금)=은행, 지출(카드결제)=카드로 기본 선택(현금 X)
walletKind: e.walletId
? walletKindOf(e.walletId)
: (e.pending ? (e.type === 'INCOME' ? 'BANK' : 'CARD') : (e.type === 'TRANSFER' ? '' : 'CASH')),
walletId: e.walletId || '',
toWalletKind: walletKindOf(e.toWalletId),
toWalletId: e.toWalletId || '',
principal: null,
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 = {}
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
rateUpdatedAt.value = null
pasteDetected.value = null
pasteModalOpen.value = false
pasteText.value = ''
formError.value = null
formInfo.value = ''
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.entryDate) {
formError.value = '거래일을 입력하세요.'
return
}
// 상환/납부: 원금=이체, 이자·연회비=지출 자동 분리
if (isRepayment.value) {
if (!form.walletId || !form.toWalletId) {
formError.value = '출금 계좌와 대상(대출/카드)을 선택하세요.'
return
}
if (form.walletId === form.toWalletId) {
formError.value = '출금/대상 계좌가 같을 수 없습니다.'
return
}
const principal = Number(form.principal) || 0
const interest = Number(form.interest) || 0
const annualFee = repayTargetIsCard.value ? (Number(form.annualFee) || 0) : 0
if (principal <= 0 && interest <= 0 && annualFee <= 0) {
formError.value = '원금·이자·연회비 중 하나는 입력하세요.'
return
}
submitting.value = true
try {
// 상환은 원금=이체 / 이자·연회비=지출로 나눠 저장된다. 수정 시에는 기존 1건을
// 상환 N건으로 다시 만든다(상환 생성 성공 후 원본 삭제 — 중간 실패 시 원본 보존).
await accountApi.repayment({
entryDate: form.entryDate,
fromWalletId: form.walletId,
targetWalletId: form.toWalletId,
principal,
interest,
annualFee,
memo: form.memo || null,
})
if (editId.value) {
await accountApi.remove(editId.value)
}
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
return
}
if (form.amount == null || form.amount < 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
if (form.type === 'TRANSFER') {
if (!form.walletId || !form.toWalletId) {
formError.value = '이체는 출금/입금 계좌를 모두 선택하세요.'
return
}
if (form.walletId === form.toWalletId) {
formError.value = '출금/입금 계좌가 같을 수 없습니다.'
return
}
}
submitting.value = true
const foreign = isForeign.value
const payload = {
entryDate: form.entryDate,
type: form.type,
category: form.category || null,
amount: Number(form.amount),
memo: form.memo || null,
walletId: form.walletId || null,
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) {
await accountApi.update(editId.value, payload)
// 미확인(카드 알림) 건이면 수정 저장 후 확정 처리
if (editingPending.value) {
await accountApi.confirmEntry(editId.value, {})
}
} else {
await accountApi.create(payload)
}
formOpen.value = false
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
await load()
await loadPendingCount()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(e) {
if (!(await dialog.confirm('이 내역을 삭제하시겠습니까?', { title: '내역 삭제', danger: true }))) return
try {
await accountApi.remove(e.id)
await load()
} catch (err) {
alert(err.response?.data?.message || '삭제에 실패했습니다.')
}
}
// 고정지출 등록 확인 모달 상태 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
const recurConfirm = reactive({ open: false, title: '', dom: 1, amount: 0, payload: null })
const flashMsg = ref('')
let flashTimer = null
function flash(msg) {
flashMsg.value = msg
if (flashTimer) clearTimeout(flashTimer)
flashTimer = setTimeout(() => (flashMsg.value = ''), 2800)
}
// 현재 수정 중인 내역을 '매월' 고정지출로 등록 — 확인 모달을 띄운다
function registerAsRecurring() {
if (form.type === 'REPAYMENT') return // 고정지출은 수입/지출/이체만
const amount = Number(form.amount)
if (!amount || amount <= 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
const title =
(form.memo || '').trim() ||
(form.category || '').trim() ||
(form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '고정 지출')
const base = form.entryDate || todayStr()
const [y, m, d] = base.split('-').map(Number)
const dom = d
// 이번 회차 중복 방지: 시작일을 내역 다음날로 → 다음 발생부터 생성
const start = new Date(y, m - 1, d + 1)
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
recurConfirm.title = title
recurConfirm.dom = dom
recurConfirm.amount = amount
recurConfirm.payload = {
title,
type: form.type,
amount,
category: form.type === 'TRANSFER' ? null : form.category || null,
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
frequency: 'MONTHLY',
dayOfMonth: dom,
startDate: startStr,
active: true,
}
recurConfirm.open = true
}
async function doRegisterRecurring() {
if (!recurConfirm.payload) return
submitting.value = true
try {
await accountApi.createRecurring(recurConfirm.payload)
recurConfirm.open = false
formOpen.value = false
flash('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
} catch (e) {
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.'
recurConfirm.open = false
} finally {
submitting.value = false
}
}
// ===== 자주 쓰는 내역(빠른 등록 템플릿) =====
const quickEntries = ref([])
const quickEditMode = ref(false)
async function loadQuick() {
try {
quickEntries.value = await accountApi.quickEntries()
} catch {
quickEntries.value = []
}
}
function quickLabel(q) {
return q.label || q.category || q.memo || (q.type === 'INCOME' ? '수입' : '지출')
}
// 칩 탭 → 오늘 날짜로 즉시 등록
async function useQuick(q) {
if (quickEditMode.value) return
// 1) 등록 — 이 단계 실패만 '등록 실패'로 처리
try {
await accountApi.create({
entryDate: todayStr(),
type: q.type,
category: q.category || null,
amount: Number(q.amount),
memo: q.memo || null,
walletId: q.walletId || null,
toWalletId: null,
installmentMonths: null,
tagIds: [],
})
} catch (e) {
flash(e.response?.data?.message || '빠른 등록에 실패했습니다.')
return
}
// 2) 등록 성공 안내 + 목록 갱신(갱신 실패는 등록 성공을 가리지 않게 분리)
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
try {
await load()
await loadPendingCount()
} catch {
/* 목록 갱신 실패는 무시 — 다음 진입/새로고침 시 반영 */
}
}
async function removeQuick(q) {
try {
await accountApi.removeQuickEntry(q.id)
await loadQuick()
} catch (e) {
flash(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
// 현재 폼 값을 자주 쓰는 내역으로 저장
async function saveAsQuick() {
if (form.type !== 'INCOME' && form.type !== 'EXPENSE') return
const amount = Number(form.amount)
formInfo.value = ''
if (!amount || amount <= 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
try {
await accountApi.createQuickEntry({
label: (form.memo || form.category || '').trim() || null,
type: form.type,
category: form.category || null,
amount,
memo: form.memo || null,
walletId: form.walletId || null,
})
await loadQuick()
formError.value = null
formInfo.value = '자주 쓰는 내역으로 저장했습니다.'
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
}
}
// ===== 문자/푸시 붙여넣기 → 파싱 자동채움 (모달) =====
const pasteDetected = ref(null) // 자동감지 결과(버튼 배지 + 모달 배너)
const pasteModalOpen = ref(false) // 붙여넣기 모달
const pasteText = ref('')
const pasteError = ref('')
function applyParsed(p) {
if (!p || !p.recognized) return
if (p.amount) form.amount = Number(p.amount)
if (p.merchant) form.memo = p.merchant
if (p.date) form.entryDate = p.date
// 계좌 매칭: 출금(from) / 받는·대상(to)
const fromW = p.wallet ? wallets.value.find((x) => x.name === p.wallet) : null
const toW = p.toWallet ? wallets.value.find((x) => x.name === p.toWallet) : null
// 이체 대상이 대출/카드면 '상환(REPAYMENT)'으로 재분류 — 앱은 계좌→대출/카드 납입을 상환으로 처리(원금=이체·이자=지출)
const isTransfer = p.type === 'TRANSFER'
const repayment = isTransfer && toW && (toW.type === 'LOAN' || toW.type === 'CARD')
form.type =
p.type === 'INCOME' ? 'INCOME' : repayment ? 'REPAYMENT' : isTransfer ? 'TRANSFER' : 'EXPENSE'
// 이체·상환은 분류 없음. 그 외엔 AI 추천 분류(후보 중)
form.category = isTransfer || repayment ? '' : p.category || ''
if (fromW) {
form.walletKind = fromW.type
form.walletId = fromW.id
}
if ((isTransfer || repayment) && toW) {
form.toWalletKind = toW.type
form.toWalletId = toW.id
}
if (repayment) {
const amt = p.amount ? Number(p.amount) : null
// toWalletId 변경 감시자가 principal/interest/납입금액을 초기화한 '뒤'에 값을 넣는다.
nextTick(() => {
const loan = repayTargetLoanWallet.value
if (loan && loanAutoResult.value && loanAutoResult.value.needsInput && amt) {
// 금리 설정된 대출(원리금균등) → 납입금액에 넣으면 이자/원금 자동 분리(calcLoanRepayment)
loanPaymentAmount.value = amt
} else if (!loan && amt) {
// 금리 미설정 대출·카드 → 전액 원금(이자는 사용자가 분리 입력)
form.principal = amt
form.interest = null
}
// BULLET(만기일시)·EQUAL_PRINCIPAL(원금균등)은 loanAutoResult 감시자가 이자/원금을 자동 채움
})
}
pasteDetected.value = null
pasteModalOpen.value = false
pasteText.value = ''
pasteError.value = ''
formOpen.value = true // 내역 화면에서 바로 빠른입력한 경우에도 채워진 추가 폼이 보이도록 보장
}
async function parseAndApply(text, { silent } = {}) {
const t = (text || '').trim()
if (!t) return false
try {
// 사용자의 실제 분류·계좌 이름을 함께 보내 AI가 그 중에서 고르게 함
const p = await accountApi.parseText({
text: t,
categoryHints: allCategoryNames.value,
walletHints: wallets.value.map((w) => w.name),
})
if (p && p.recognized) {
if (silent) pasteDetected.value = p
else applyParsed(p)
return true
}
} catch (e) {
// 네트워크 오류는 조용히 폴백하되, 예외는 콘솔에 남겨 원인 추적이 가능하게 한다
console.error('[parseAndApply] 실패:', e)
}
return false
}
// 추가 팝업 열 때: 클립보드 자동 읽기 제거(iOS 는 clipboard.readText() 마다 '붙여넣기' 시스템 토스트를 띄움).
// 배지 표시용 감지도 하지 않는다. 붙여넣기는 사용자가 모달 안에서 직접 한다.
function tryClipboardOnOpen() {
pasteDetected.value = null
}
// '빠른 입력' 버튼 → 모달 열기(자동 클립보드 읽기 없음). 사용자가 자연어 입력 또는 직접 붙여넣기.
function openPasteModal() {
pasteError.value = ''
pasteModalOpen.value = true
}
// 모달에서 분석·채우기
async function analyzePasted() {
pasteError.value = ''
const ok = await parseAndApply(pasteText.value, { silent: false })
if (!ok) pasteError.value = '거래 내용을 인식하지 못했습니다. 금액과 승인/입금 등이 포함됐는지 확인하세요.'
}
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
// 고정지출은 유료 전용 — 무료 회원은 호출 자체를 건너뛴다(403/업그레이드 리다이렉트 방지)
if (isPremium.value) {
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
}
}
load()
loadTagOptions()
loadWallets()
loadCategories()
loadPendingCount()
loadQuick()
checkNotifPermission()
})
</script>
<template>
<section class="account">
<header v-if="pendingCount" class="account-head">
<span class="pending-count">확인 필요 {{ pendingCount }}</span>
</header>
<Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition>
<!-- 자주 쓰는 내역(빠른 등록) 오늘 날짜로 즉시 등록 -->
<div v-if="quickEntries.length" class="quick-bar">
<span class="quick-bar-label"> 자주 쓰는 내역</span>
<button
v-for="q in quickEntries" :key="q.id" type="button" class="quick-chip"
:title="quickEditMode ? '' : '오늘 날짜로 바로 등록'" @click="useQuick(q)"
>
<span class="qc-label">{{ quickLabel(q) }}</span>
<span class="qc-amt" :class="q.type === 'INCOME' ? 'income' : 'expense'">{{ q.type === 'INCOME' ? '+' : '-' }}{{ won(q.amount) }}</span>
<span v-if="quickEditMode" class="qc-del" title="삭제" @click.stop="removeQuick(q)">×</span>
</button>
<button type="button" class="quick-edit" @click="quickEditMode = !quickEditMode">{{ quickEditMode ? '완료' : '편집' }}</button>
</div>
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) -->
<div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span>
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
</div>
<div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<div class="mn-actions">
<IconBtn
icon="filter" title="검색·필터" class="filter-toggle"
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
/>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</div>
</div>
<div v-if="filterOpen" class="filter-bar">
<input
v-model="filters.keyword" type="text" class="f-keyword"
placeholder="메모·분류 검색" @keyup.enter="applyFilters"
/>
<select v-model="filters.type">
<option value="">구분 전체</option>
<option value="INCOME">수입</option>
<option value="EXPENSE">지출</option>
<option value="TRANSFER">이체</option>
</select>
<select v-model="filters.category">
<option value="">분류 전체</option>
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="filters.walletId">
<option value="">계좌 전체</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
</select>
<select v-model="filters.tagId">
<option value="">태그 전체</option>
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
<IconBtn icon="refresh" title="초기화" @click="resetFilters" />
</div>
<div class="summary">
<div class="card income">
<span class="label">수입</span>
<span class="value">+{{ won(summary.totalIncome) }}</span>
</div>
<div class="card expense">
<span class="label">지출</span>
<span class="value">-{{ won(summary.totalExpense) }}</span>
</div>
<div class="card balance">
<span class="label">잔액</span>
<span class="value">{{ won(summary.balance) }}</span>
</div>
</div>
<!-- 자연어 빠른 입력 진입점(AI): 입력 자동 채움 -->
<button type="button" class="quick-input-bar" @click="openQuickInput">
<span class="qib-icon" aria-hidden="true"></span>
<span class="qib-body">
<span class="qib-title">빠른 입력</span>
<span class="qib-sub">어제 삼성카드로 라면 5천원 처럼 줄로</span>
</span>
<span class="qib-plus" aria-hidden="true"></span>
</button>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<div v-else-if="entries.length" class="day-list">
<div class="list-tools">
<button type="button" class="toggle-all" @click="toggleAll">{{ allExpanded ? '모두 접기 ' : '모두 펴기 ' }}</button>
</div>
<section v-for="g in entriesByDay" :key="g.date" class="day-group">
<div class="day-head" @click="toggleDate(g.date)">
<span class="day-head-left">
<span class="day-toggle">{{ isExpanded(g.date) ? '▾' : '▸' }}</span>
<span class="day-date">{{ dayLabel(g.date) }}</span>
<span v-if="!isExpanded(g.date)" class="day-count">{{ g.items.length }}</span>
</span>
<span class="day-sums">
<span v-if="g.income" class="income">+{{ won(g.income) }}</span>
<span v-if="g.expense" class="expense">-{{ won(g.expense) }}</span>
</span>
</div>
<ul v-show="isExpanded(g.date)" class="day-items">
<li v-for="e in g.items" :key="e.id" class="entry-item" :class="{ 'is-pending': e.pending }">
<div class="ei-line1">
<span class="ei-cat">
<span class="type-dot" :class="dotClass(e.type)"></span>
<template v-if="e.type === 'TRANSFER'">이체</template>
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
</span>
<span v-if="e.pending" class="ei-pending">확인필요</span>
<span v-if="e.type === 'TRANSFER'" class="ei-wallet">{{ e.walletName }} {{ e.toWalletName }}</span>
<span v-else-if="e.walletName" class="ei-wallet">{{ e.walletName }}</span>
<span class="ei-amount" :class="amountClass(e.type)">{{ amountSign(e.type) }}{{ won(e.amount) }}</span>
<span class="ei-act">
<button v-if="e.pending" type="button" class="confirm-btn" title="확인(확정)" @click="confirmRow(e)">확인</button>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span>
</div>
<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.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
</div>
</li>
</ul>
</section>
</div>
<p v-else-if="!loading && hasFilter" class="msg">조건에 맞는 내역이 없습니다.</p>
<div v-else-if="!loading" class="empty-state">
<p class="empty-emoji">📒</p>
<p class="empty-title">아직 기록된 내역이 없어요</p>
<p class="empty-desc"> 수입·지출을 기록해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 내역 추가</button>
</div>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="slide-up">
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
<div class="modal entry-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
<form class="entry-form" @submit.prevent="submit">
<!-- 영수증 스캔 · 문자/푸시 (같은 상단) -->
<div v-if="!isRepayment" class="receipt-box">
<input
ref="receiptInput" type="file" accept="image/*"
:capture="webCapture ? 'environment' : undefined"
class="receipt-input" @change="onReceiptFile"
/>
<div class="rb-actions">
<button
v-if="isPremium"
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
<button
type="button" class="paste-btn" :class="{ detected: pasteDetected }"
:disabled="submitting" @click="openPasteModal"
> 빠른 입력<span v-if="pasteDetected" class="pb-dot"></span></button>
</div>
<span v-if="ocrRunning" class="receipt-status">영수증 인식 </span>
<span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.ai"> </template>
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
<template v-if="ocrResult.category"> · {{ ocrResult.category }}</template>
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
자동 입력됨
</span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호·분류를 자동 입력</span>
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<!-- 구분 배지 -->
<div class="field">
<span class="field-label">구분</span>
<div class="type-chips">
<button v-for="t in TYPES" :key="t.value" type="button"
class="chip" :class="[t.cls, { active: form.type === t.value }]"
:disabled="submitting" @click="form.type = t.value; onTypeChange()">
{{ t.label }}
</button>
</div>
</div>
<!-- 계좌 종류 먼저 선택(배지) 해당 종류만 셀렉트에 -->
<div class="field">
<span class="field-label">계좌 종류</span>
<div class="wallet-chips">
<button v-for="k in fromWalletKinds" :key="k.value" type="button"
class="chip" :class="{ active: form.walletKind === k.value }"
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
{{ k.label }}
</button>
</div>
<ChipSelect
v-if="form.walletKind"
v-model="form.walletId"
:options="fromWalletOptions"
:disabled="submitting"
:empty-text="`등록된 ${walletKindLabel}() 없습니다`"
/>
</div>
<!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) -->
<label v-if="showInstallment">할부
<select v-model="form.installmentMonths" :disabled="submitting">
<option value="">일시불</option>
<option v-for="m in 23" :key="m + 1" :value="m + 1">{{ m + 1 }}개월</option>
</select>
<small v-if="installmentMonthly" class="install-hint"> {{ won(installmentMonthly) }}</small>
</label>
<template v-if="form.type === 'TRANSFER'">
<div class="field">
<span class="field-label">입금 종류</span>
<div class="wallet-chips">
<button v-for="k in toWalletKinds" :key="k.value" type="button"
class="chip" :class="{ active: form.toWalletKind === k.value }"
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
{{ k.label }}
</button>
</div>
<ChipSelect
v-if="form.toWalletKind"
v-model="form.toWalletId"
:options="toWalletOptions"
:disabled="submitting"
empty-text="등록된 계좌가 없습니다"
/>
</div>
</template>
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 (+카드면 연회비). 원금=이체·이자/연회비=지출 -->
<template v-if="isRepayment">
<div class="field">
<span class="field-label">대상(대출/카드)</span>
<ChipSelect
v-model="form.toWalletId"
:options="repayTargetOptions"
:disabled="submitting"
empty-text="등록된 대출/카드가 없습니다"
/>
</div>
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<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.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>
</template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<CategoryPicker
v-model="form.category"
:type="form.type"
:categories="categories"
:disabled="submitting"
@category-added="loadCategories"
/>
</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">
<div class="fx-rate-field">
<span class="fx-label">환율(1 {{ form.currency }})</span>
<div class="fx-rate-input">
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button>
</div>
<span v-if="rateAgeLabel" class="fx-age">{{ rateAgeLabel }}</span>
</div>
<span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
</div>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment && isPremium" class="tag-field">
<span class="tag-label">태그</span>
<div class="tag-badges">
<button
v-for="t in tagOptions"
:key="t.id"
type="button"
class="tag-badge"
:class="{ active: selectedTagIds.includes(t.id) }"
@click="toggleTag(t.id)"
>{{ t.name }}</button>
<span v-if="!tagOptions.length" class="tag-empty">등록된 태그가 없습니다 (태그 관리에서 추가)</span>
</div>
</div>
<p v-if="formError" class="msg error">{{ formError }}</p>
<p v-if="formInfo" class="msg ok">{{ formInfo }}</p>
<button
v-if="isPremium && editId && form.type !== 'REPAYMENT'"
type="button"
class="to-recurring"
:disabled="submitting"
@click="registerAsRecurring"
>🔁 내역을 고정지출로 등록</button>
<button
v-if="editId && (form.type === 'INCOME' || form.type === 'EXPENSE')"
type="button"
class="to-recurring to-quick"
:disabled="submitting"
@click="saveAsQuick"
> 자주 쓰는 내역으로 저장</button>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
<!-- 영수증 등록 방식 선택 (카메라/갤러리) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="receiptPickerOpen" class="modal-backdrop picker-backdrop" @click.self="receiptPickerOpen = false">
<div class="modal picker-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="receiptPickerOpen = false">×</button>
<h2>영수증 등록</h2>
<div class="picker-options">
<button type="button" class="picker-opt" @click="chooseCamera">
<span class="po-icon">📷</span>
<span class="po-label">카메라 촬영</span>
<span class="po-desc">지금 영수증을 찍어요</span>
</button>
<button type="button" class="picker-opt" @click="chooseGallery">
<span class="po-icon">🖼</span>
<span class="po-label">갤러리에서 선택</span>
<span class="po-desc">저장된 사진을 골라요</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 고정지출 등록 확인 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="recurConfirm.open" class="modal-backdrop" @click.self="recurConfirm.open = false">
<div class="modal confirm-modal" role="dialog" aria-modal="true">
<h2>고정지출 등록</h2>
<p class="confirm-body">
<b>{{ recurConfirm.title }}</b><br />
매월 <b>{{ recurConfirm.dom }}</b> · <b>{{ won(recurConfirm.amount) }}</b> 으로 등록할까요?
</p>
<p class="confirm-sub">주기·시작일은 고정지출 화면에서 변경할 있어요.</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="recurConfirm.open = false" />
<IconBtn icon="save" title="등록" variant="primary" :disabled="submitting" @click="doRegisterRecurring" />
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 자주 쓰는 내역 불러오기 (모달 피커) -->
<!-- 문자/푸시 붙여넣기 (모달) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="pasteModalOpen" class="modal-backdrop quick-picker-backdrop" @click.self="pasteModalOpen = false">
<div class="modal paste-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="pasteModalOpen = false">×</button>
<h2>빠른 입력</h2>
<p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p>
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원&#10;또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27"></textarea>
<div v-if="pasteDetected && pasteDetected.amount" class="paste-detected">
감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
</div>
<p v-if="pasteError" class="msg error">{{ pasteError }}</p>
<div class="buttons pm-buttons">
<button type="button" class="pm-btn pm-cancel" @click="pasteModalOpen = false">취소</button>
<button type="button" class="pm-btn pm-primary" @click="analyzePasted"> 분석해서 채우기</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.account {
max-width: 760px;
margin: 0 auto;
}
.account-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
}
/* 좌측 spacer + 우측 액션을 같은 flex:1 로 둬서 가운데 네비를 정확히 중앙 유지 */
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
.filter-toggle {
font-size: 0.82rem;
padding: 0.35rem 0.7rem;
}
.filter-toggle.on {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.filter-badge {
margin-left: 0.25rem;
font-size: 0.6rem;
vertical-align: middle;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.filter-bar input,
.filter-bar select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
}
.filter-bar .f-keyword {
flex: 1;
min-width: 8rem;
}
.summary {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
/* 자연어 빠른 입력 바 */
.quick-input-bar {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
margin-bottom: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 0.45);
border-radius: 12px;
background: hsla(160, 100%, 37%, 0.08);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.quick-input-bar:hover {
background: hsla(160, 100%, 37%, 0.14);
}
.quick-input-bar .qib-icon {
font-size: 1.25rem;
line-height: 1;
}
.quick-input-bar .qib-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.quick-input-bar .qib-title {
font-weight: 700;
font-size: 0.95rem;
color: hsla(160, 100%, 32%, 1);
}
.quick-input-bar .qib-sub {
font-size: 0.78rem;
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-input-bar .qib-plus {
font-size: 1.3rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
flex: 0 0 auto;
}
.card {
flex: 1;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.card .label {
font-size: 0.85rem;
opacity: 0.7;
}
.card .value {
font-size: 1.15rem;
font-weight: 700;
}
.card.income .value {
color: #2e7d32;
}
.card.expense .value {
color: #c0392b;
}
/* 일별 그룹 목록 */
.day-group {
margin-bottom: 1rem;
}
.list-tools {
display: flex;
justify-content: flex-end;
margin-bottom: 0.4rem;
}
.toggle-all {
border: 1px solid var(--color-border);
background: transparent;
color: inherit;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
cursor: pointer;
opacity: 0.8;
}
.day-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.3rem 0.1rem;
border-bottom: 2px solid var(--color-border);
margin-bottom: 0.2rem;
cursor: pointer;
user-select: none;
}
.day-head-left {
display: flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
}
.day-toggle {
font-size: 0.7rem;
opacity: 0.55;
}
.day-date {
font-size: 0.9rem;
font-weight: 700;
}
.day-count {
font-size: 0.72rem;
opacity: 0.5;
}
.day-sums {
display: flex;
gap: 0.7rem;
font-size: 0.85rem;
font-weight: 600;
}
.day-sums .income {
color: #2e7d32;
}
.day-sums .expense {
color: #c0392b;
}
.day-items {
list-style: none;
}
.entry-item {
padding: 0.5rem 0.1rem;
border-bottom: 1px solid var(--color-border);
}
.entry-item.is-pending {
background: rgba(230, 126, 34, 0.07);
}
.ei-pending {
font-size: 0.7rem;
font-weight: 600;
color: #e67e22;
border: 1px solid #e67e22;
border-radius: 3px;
padding: 0.02rem 0.3rem;
white-space: nowrap;
}
.confirm-btn {
padding: 0.18rem 0.5rem;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 4px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.notif-banner {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.6rem 0.8rem;
margin-bottom: 1rem;
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 8px;
background: hsla(160, 100%, 37%, 0.08);
font-size: 0.83rem;
}
.notif-btn {
margin-left: auto;
padding: 0.35rem 0.8rem;
font-size: 0.82rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 4px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.pending-count {
margin-left: 0.5rem;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #e67e22;
border: 1px solid #e67e22;
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
.ei-line1 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ei-cat {
font-weight: 500;
white-space: nowrap;
}
.ei-wallet {
font-size: 0.74rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 45%;
}
.ei-amount {
margin-left: auto;
font-weight: 700;
white-space: nowrap;
}
.ei-amount.income {
color: #2e7d32;
}
.ei-amount.expense {
color: #c0392b;
}
.ei-amount.transfer {
color: var(--color-text);
opacity: 0.8;
}
.ei-act {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.ei-line2 {
margin-top: 0.25rem;
padding-left: 1.1rem;
font-size: 0.8rem;
opacity: 0.85;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.type-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.35rem;
}
.type-dot.income {
background: #2e7d32;
}
.type-dot.expense {
background: #c0392b;
}
.type-dot.transfer {
background: #888;
}
.msg {
margin: 1rem 0;
}
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.4rem 1rem 2rem;
}
.empty-emoji {
font-size: 2.6rem;
}
.empty-title {
margin-top: 0.6rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
}
.empty-desc {
margin-top: 0.3rem;
font-size: 0.88rem;
opacity: 0.7;
}
.empty-cta {
margin-top: 1.1rem;
padding: 0.7rem 1.6rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
.msg.error {
color: #c0392b;
}
.msg.ok {
margin: 0.5rem 0;
color: hsla(160, 100%, 30%, 1);
font-weight: 600;
font-size: 0.88rem;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.flash {
margin: 0.5rem 0 0;
padding: 0.6rem 0.9rem;
background: hsla(160, 100%, 37%, 0.12);
border: 1px solid hsla(160, 100%, 37%, 0.5);
border-radius: 8px;
font-size: 0.85rem;
}
.confirm-modal {
max-width: 340px;
}
.confirm-body {
margin: 0.5rem 0;
line-height: 1.5;
}
.confirm-sub {
margin: 0 0 0.5rem;
font-size: 0.8rem;
opacity: 0.6;
}
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
.entry-backdrop {
padding: 0;
}
.modal.entry-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
padding-top: calc(1.5rem + env(safe-area-inset-top));
}
/* 넓은 화면(PC)에서도 입력 내용은 읽기 좋은 폭으로 가운데 정렬 */
.modal.entry-modal > h2,
.modal.entry-modal .entry-form {
max-width: 460px;
margin-left: auto;
margin-right: auto;
}
/* 전체화면 내역 모달 슬라이드업 */
.slide-up-enter-active {
transition: background 0.25s ease;
}
.slide-up-leave-active {
transition: background 0.2s ease;
}
.slide-up-enter-from.entry-backdrop,
.slide-up-leave-to.entry-backdrop {
background: rgba(0, 0, 0, 0);
}
.slide-up-enter-active .modal.entry-modal {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-up-leave-active .modal.entry-modal {
transition: transform 0.22s cubic-bezier(0.4, 0, 1, 1);
}
.slide-up-enter-from .modal.entry-modal,
.slide-up-leave-to .modal.entry-modal {
transform: translateY(100%);
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
/* backdrop 이 flex 라 모달(flex 아이템) 기본 min-width:auto 가 내용물만큼 모달을 뷰포트보다 넓힘 → 0 으로 고정 */
min-width: 0;
/* 폼이 길어도 모달 안에서 스크롤되도록 + 하단 소프트키/제스처바 안전영역 확보 */
max-height: calc(100vh - 2rem);
overflow-y: auto;
overflow-x: hidden; /* 내부 요소가 가로로 넘쳐도 페이지가 밀리지 않게(넓은 그리드·입력행 방지) */
padding: 1.75rem 1.5rem calc(1.5rem + env(safe-area-inset-bottom));
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
z-index: 1;
}
/* 전체화면 모달(entry)만: 닫기 버튼을 상태바/노치 아래로 — 중앙 모달엔 적용 안 함 */
.modal.entry-modal .close {
top: calc(0.5rem + env(safe-area-inset-top));
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.entry-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.receipt-box {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.rb-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.receipt-input {
display: none;
}
.receipt-btn {
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
background: var(--color-background);
color: hsla(160, 100%, 37%, 1);
cursor: pointer;
white-space: nowrap;
}
.receipt-hint {
font-size: 0.76rem;
opacity: 0.6;
}
.receipt-status {
font-size: 0.78rem;
opacity: 0.85;
}
.receipt-status.ok {
color: hsla(160, 100%, 37%, 1);
}
/* 인식 중 무한 로딩 바 */
.receipt-progress {
flex-basis: 100%;
height: 4px;
border-radius: 2px;
background: var(--color-background-mute);
overflow: hidden;
}
.receipt-progress .bar {
width: 40%;
height: 100%;
border-radius: 2px;
background: hsla(160, 100%, 37%, 1);
animation: receipt-indeterminate 1.1s ease-in-out infinite;
}
@keyframes receipt-indeterminate {
0% { margin-left: -40%; }
100% { margin-left: 100%; }
}
/* 영수증 등록 방식 선택 팝업 */
.picker-backdrop {
z-index: 1200; /* 내역 모달(1000) 위에 */
}
.picker-modal {
max-width: 320px;
}
.picker-options {
display: flex;
gap: 0.7rem;
}
.picker-opt {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 1.1rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.picker-opt:hover {
border-color: hsla(160, 100%, 37%, 0.6);
transform: translateY(-1px);
}
.po-icon {
font-size: 1.7rem;
line-height: 1;
}
.po-label {
font-weight: 600;
font-size: 0.9rem;
}
.po-desc {
font-size: 0.72rem;
opacity: 0.6;
text-align: center;
}
.entry-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.entry-form input,
.entry-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
/* 배지형 선택 (구분 · 계좌 종류) */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.type-chips,
.wallet-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 구분 배지 active 색상 */
.chip-expense.active { border-color: #c0392b; background: #c0392b; color: #fff; }
.chip-income.active { border-color: #2e7d32; background: #2e7d32; color: #fff; }
.chip-transfer.active { border-color: #1565c0; background: #1565c0; color: #fff; }
.chip-repay.active { border-color: #6a1fa2; background: #6a1fa2; color: #fff; }
/* 계좌 종류 배지 active 색상 */
.wallet-chips .chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.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 {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.tag-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag-badge {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.tag-badge.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.12);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.tag-empty {
font-size: 0.8rem;
opacity: 0.6;
}
.row-tag {
margin-left: 0.35rem;
font-size: 0.75rem;
color: hsla(160, 100%, 37%, 1);
}
.install-hint {
font-size: 0.75rem;
opacity: 0.65;
margin-top: 0.1rem;
}
.ei-install {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(160, 100%, 37%, 0.5);
border-radius: 3px;
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: flex-end;
gap: 0.6rem;
flex-wrap: wrap;
margin: -0.3rem 0 0.2rem;
}
.fx-rate-field {
display: flex;
flex-direction: column;
}
.fx-label {
font-size: 0.8rem;
margin-bottom: 0.2rem;
}
/* input 과 버튼을 한 행으로 — stretch 로 버튼 높이를 input 에 맞춤 */
.fx-rate-input {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.fx-rate-input input {
width: 7rem;
}
.fx-auto {
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.fx-krw {
font-weight: 700;
color: #b8860b;
font-size: 0.9rem;
padding-bottom: 0.45rem;
}
.fx-age {
font-size: 0.7rem;
color: var(--color-text-soft, #999);
margin-top: 0.18rem;
}
.row-wallet {
margin-right: 0.35rem;
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
opacity: 0.85;
}
/* 자주 쓰는 내역 칩 */
.quick-bar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.4rem 0 0.2rem;
align-items: center;
}
.quick-bar-label {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.65;
margin-right: 0.2rem;
}
.quick-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.quick-chip .qc-label {
font-weight: 600;
}
.quick-chip .qc-amt.income {
color: #2e7d32;
}
.quick-chip .qc-amt.expense {
color: #c0392b;
}
.quick-chip .qc-del {
color: #c0392b;
font-weight: 700;
padding: 0 0.1rem;
}
.quick-edit {
border: 0;
background: transparent;
color: inherit;
opacity: 0.6;
font-size: 0.78rem;
cursor: pointer;
}
/* 문자/푸시 붙여넣기 */
.paste-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.45rem 0.85rem;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
}
.paste-btn.detected {
border-color: hsla(160, 100%, 37%, 0.7);
}
.paste-btn .pb-dot {
color: hsla(160, 100%, 37%, 1);
font-size: 0.6rem;
}
.paste-detected {
font-size: 0.82rem;
background: hsla(160, 100%, 37%, 0.1);
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin: 0.5rem 0;
}
.paste-modal .pm-hint {
font-size: 0.82rem;
opacity: 0.7;
margin: 0 0 0.6rem;
}
.paste-modal .pm-text {
width: 100%;
box-sizing: border-box;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font: inherit;
resize: vertical;
}
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
.paste-modal .pm-buttons {
bottom: 0;
gap: 0.6rem;
padding-bottom: calc(0.2rem + env(safe-area-inset-bottom));
}
.pm-btn {
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
border: 1px solid var(--color-border);
}
.pm-cancel {
flex: 0 0 auto;
min-width: 84px;
background: var(--color-background-soft);
color: var(--color-text);
}
.pm-primary {
flex: 1;
background: hsla(160, 100%, 37%, 1);
border-color: hsla(160, 100%, 37%, 1);
color: #fff;
}
.to-recurring {
display: block;
width: 100%;
margin-top: 0.6rem;
padding: 0.55rem;
font-size: 0.85rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
background: transparent;
border: 1px dashed hsla(160, 100%, 37%, 0.6);
border-radius: 8px;
cursor: pointer;
}
.to-quick {
color: #b8860b;
border-color: rgba(184, 134, 11, 0.6);
}
/* 자주 쓰는 내역 불러오기 피커 모달 */
.quick-picker-backdrop {
z-index: 1300; /* 내역 모달 위 */
}
.qp-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 60vh;
overflow-y: auto;
}
.qp-list li {
border-bottom: 1px solid var(--color-border);
}
.qp-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.7rem 0.4rem;
background: transparent;
border: 0;
color: var(--color-text);
font: inherit;
cursor: pointer;
text-align: left;
}
.qp-item:hover {
background: var(--color-background-soft);
}
.qp-label {
font-weight: 600;
}
.qp-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 0 0 auto;
}
.qp-cat {
font-size: 0.78rem;
opacity: 0.6;
}
.qp-amt.income {
color: #2e7d32;
font-weight: 600;
}
.qp-amt.expense {
color: #c0392b;
font-weight: 600;
}
.to-recurring:disabled {
opacity: 0.5;
cursor: default;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
/* 폼이 길 때도 저장/취소 버튼이 항상 보이도록 하단 고정 */
position: sticky;
bottom: calc(-1.5rem - env(safe-area-inset-bottom));
padding: 0.6rem 0 0.2rem;
background: var(--color-background);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일 ===== */
@media (max-width: 768px) {
.account {
max-width: 100%;
}
.summary {
gap: 0.5rem;
}
.card {
padding: 0.6rem 0.7rem;
}
.card .value {
font-size: 1rem;
}
.ei-wallet {
max-width: 38%;
}
.filter-bar .f-keyword {
flex-basis: 100%;
}
}
</style>