2026-07-02 01:43:01 +09:00
|
|
|
|
<script setup>
|
2026-07-05 14:52:00 +09:00
|
|
|
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
import { accountApi } from '@/api/accountApi'
|
2026-06-28 17:42:00 +09:00
|
|
|
|
import { reminders } from '@/native/reminders'
|
2026-06-28 11:02:44 +09:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
2026-06-24 21:57:50 +09:00
|
|
|
|
import { useDialog } from '@/composables/dialog'
|
2026-06-29 20:25:50 +09:00
|
|
|
|
import { appLock } from '@/composables/appLock'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
2026-07-04 01:24:15 +09:00
|
|
|
|
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
2026-07-06 23:06:17 +09:00
|
|
|
|
import ChipSelect from '@/components/ui/ChipSelect.vue'
|
2026-06-03 17:37:35 +09:00
|
|
|
|
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
2026-06-03 18:55:48 +09:00
|
|
|
|
import { cardNotif } from '@/native/cardNotif'
|
2026-06-28 18:43:22 +09:00
|
|
|
|
import { CARD_NOTIF_ENABLED } from '@/config/features'
|
2026-06-03 16:50:54 +09:00
|
|
|
|
import { Capacitor } from '@capacitor/core'
|
2026-06-03 18:29:00 +09:00
|
|
|
|
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-24 21:57:50 +09:00
|
|
|
|
const dialog = useDialog()
|
2026-06-28 11:02:44 +09:00
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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)
|
2026-06-03 18:51:43 +09:00
|
|
|
|
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
|
|
|
|
|
|
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
|
|
|
|
|
|
|
2026-06-03 18:55:48 +09:00
|
|
|
|
// 카드 결제 알림 자동인식 권한 (네이티브 전용)
|
2026-06-28 18:43:22 +09:00
|
|
|
|
const notifNative = CARD_NOTIF_ENABLED && cardNotif.isNative()
|
2026-06-03 18:55:48 +09:00
|
|
|
|
const notifEnabled = ref(true) // 미허용일 때만 배너 노출
|
|
|
|
|
|
async function checkNotifPermission() {
|
|
|
|
|
|
if (notifNative) notifEnabled.value = await cardNotif.isEnabled()
|
|
|
|
|
|
}
|
|
|
|
|
|
function openNotifSettings() {
|
|
|
|
|
|
cardNotif.openSettings()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 18:51:43 +09:00
|
|
|
|
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, {})
|
2026-06-06 00:16:50 +09:00
|
|
|
|
e.pending = false // UI 즉시 반영(목록 재로딩 전)
|
2026-06-03 18:51:43 +09:00
|
|
|
|
await load()
|
|
|
|
|
|
await loadPendingCount()
|
|
|
|
|
|
} catch (err) {
|
2026-06-06 00:16:50 +09:00
|
|
|
|
const st = err.response?.status
|
|
|
|
|
|
alert(err.response?.data?.message || `확인 처리에 실패했습니다.${st ? ' (HTTP ' + st + ')' : ' (네트워크 오류)'}`)
|
2026-06-03 18:51:43 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
// 검색·필터
|
|
|
|
|
|
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)
|
2026-06-30 21:37:24 +09:00
|
|
|
|
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)
|
2026-07-05 14:52:00 +09:00
|
|
|
|
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)}일 전`
|
|
|
|
|
|
})
|
2026-06-30 21:37:24 +09:00
|
|
|
|
async function fetchRate() {
|
|
|
|
|
|
if (!isForeign.value || fxLoading.value) return
|
|
|
|
|
|
fxLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await accountApi.fxRate(form.currency, 'KRW')
|
2026-06-30 21:56:04 +09:00
|
|
|
|
// 환율은 소수점 4자리로 통일(입력폼 step·저장 정밀도 일치)
|
2026-07-05 14:52:00 +09:00
|
|
|
|
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 = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
|
|
|
|
|
|
}
|
2026-06-30 21:37:24 +09:00
|
|
|
|
} 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
|
2026-07-05 14:52:00 +09:00
|
|
|
|
rateUpdatedAt.value = null
|
2026-06-30 21:37:24 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 00:56:14 +09:00
|
|
|
|
// 계좌/카드 (repayTargetIsCard·repayTargetLoanWallet computed가 참조하므로 먼저 선언)
|
|
|
|
|
|
const wallets = ref([])
|
2026-05-31 15:42:52 +09:00
|
|
|
|
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
2026-06-12 22:52:34 +09:00
|
|
|
|
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
|
|
|
|
|
|
const repayTargetIsCard = computed(() => {
|
|
|
|
|
|
const w = wallets.value.find((x) => x.id === form.toWalletId)
|
|
|
|
|
|
return !!w && w.type === 'CARD'
|
|
|
|
|
|
})
|
2026-07-02 00:02:10 +09:00
|
|
|
|
// 금리가 설정된 대출 계좌를 선택한 경우 자동계산 활성
|
|
|
|
|
|
const repayTargetLoanWallet = computed(() => {
|
|
|
|
|
|
const w = wallets.value.find((x) => x.id === form.toWalletId)
|
|
|
|
|
|
return w?.type === 'LOAN' && w.loanRate ? w : null
|
|
|
|
|
|
})
|
2026-07-02 00:31:50 +09:00
|
|
|
|
// 납입금액 입력용 (원리금균등만 사용)
|
2026-07-02 00:02:10 +09:00
|
|
|
|
const loanPaymentAmount = ref(null)
|
2026-07-02 00:31:50 +09:00
|
|
|
|
|
|
|
|
|
|
function monthlyInterestOf(loan) {
|
|
|
|
|
|
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
|
|
|
|
|
|
const loanAutoResult = computed(() => {
|
2026-07-02 00:02:10 +09:00
|
|
|
|
const loan = repayTargetLoanWallet.value
|
2026-07-02 00:31:50 +09:00
|
|
|
|
if (!loan) return null
|
|
|
|
|
|
const interest = monthlyInterestOf(loan)
|
2026-07-02 00:02:10 +09:00
|
|
|
|
if (loan.loanMethod === 'BULLET') {
|
2026-07-02 00:31:50 +09:00
|
|
|
|
return { interest, principal: 0, total: interest, needsInput: false }
|
2026-07-02 00:02:10 +09:00
|
|
|
|
}
|
2026-07-02 00:31:50 +09:00
|
|
|
|
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)
|
2026-07-02 00:02:10 +09:00
|
|
|
|
}
|
2026-07-02 00:31:50 +09:00
|
|
|
|
watch(loanPaymentAmount, (val) => {
|
|
|
|
|
|
if (loanAutoResult.value?.needsInput && val) calcLoanRepayment(Number(val))
|
|
|
|
|
|
})
|
2026-07-02 00:02:10 +09:00
|
|
|
|
watch(() => form.toWalletId, () => {
|
|
|
|
|
|
loanPaymentAmount.value = null
|
|
|
|
|
|
form.principal = null
|
|
|
|
|
|
form.interest = null
|
|
|
|
|
|
})
|
2026-06-03 16:42:07 +09:00
|
|
|
|
// 카드 지출일 때만 할부 입력 노출 (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
|
|
|
|
|
|
})
|
2026-05-31 15:42:52 +09:00
|
|
|
|
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
|
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
const formError = ref(null)
|
2026-06-27 15:08:59 +09:00
|
|
|
|
const formInfo = ref('') // 모달 내 안내(예: 자주 쓰는 내역 저장됨)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-03 16:42:07 +09:00
|
|
|
|
// 영수증 OCR (온디바이스)
|
|
|
|
|
|
const receiptInput = ref(null)
|
|
|
|
|
|
const ocrRunning = ref(false)
|
|
|
|
|
|
const ocrResult = ref(null) // { amount, date, store }
|
2026-06-03 18:07:01 +09:00
|
|
|
|
const receiptPickerOpen = ref(false) // 카메라/갤러리 선택 레이어
|
|
|
|
|
|
const webCapture = ref(false) // 웹: 카메라 버튼이면 capture 활성
|
|
|
|
|
|
// 영수증 등록: 커스텀 레이어 팝업(카메라/갤러리 선택)
|
|
|
|
|
|
function pickReceipt() {
|
2026-06-03 16:42:07 +09:00
|
|
|
|
ocrResult.value = null
|
2026-06-03 18:07:01 +09:00
|
|
|
|
receiptPickerOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
async function chooseCamera() {
|
|
|
|
|
|
receiptPickerOpen.value = false
|
2026-06-03 16:50:54 +09:00
|
|
|
|
if (Capacitor.isNativePlatform()) {
|
2026-06-03 18:29:00 +09:00
|
|
|
|
await captureFrom('CAMERA')
|
2026-06-03 18:07:01 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
webCapture.value = true
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
receiptInput.value?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function chooseGallery() {
|
|
|
|
|
|
receiptPickerOpen.value = false
|
|
|
|
|
|
if (Capacitor.isNativePlatform()) {
|
2026-06-03 18:29:00 +09:00
|
|
|
|
await captureFrom('PHOTOS')
|
2026-06-03 16:50:54 +09:00
|
|
|
|
} else {
|
2026-06-03 18:07:01 +09:00
|
|
|
|
webCapture.value = false
|
|
|
|
|
|
await nextTick()
|
2026-06-03 16:50:54 +09:00
|
|
|
|
receiptInput.value?.click()
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
|
}
|
2026-06-03 18:29:00 +09:00
|
|
|
|
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
|
|
|
|
|
|
async function captureFrom(sourceKey) {
|
2026-06-03 18:07:01 +09:00
|
|
|
|
try {
|
2026-06-29 20:25:50 +09:00
|
|
|
|
appLock.suppressLock() // 카메라 복귀 시 앱 잠금 오작동 방지
|
2026-06-03 18:29:00 +09:00
|
|
|
|
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
|
2026-06-03 18:07:01 +09:00
|
|
|
|
const photo = await Camera.getPhoto({
|
2026-06-03 18:29:00 +09:00
|
|
|
|
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
|
2026-06-03 18:07:01 +09:00
|
|
|
|
resultType: CameraResultType.DataUrl,
|
|
|
|
|
|
quality: 70,
|
|
|
|
|
|
correctOrientation: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (photo?.dataUrl) await runOcr(photo.dataUrl)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 사용자가 취소 → 무시
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
|
async function onReceiptFile(e) {
|
|
|
|
|
|
const file = e.target.files?.[0]
|
|
|
|
|
|
e.target.value = '' // 같은 파일 재선택 허용
|
2026-06-03 16:50:54 +09:00
|
|
|
|
if (file) await runOcr(file)
|
|
|
|
|
|
}
|
|
|
|
|
|
async function runOcr(image) {
|
2026-06-03 16:42:07 +09:00
|
|
|
|
ocrRunning.value = true
|
|
|
|
|
|
ocrResult.value = null
|
|
|
|
|
|
formError.value = null
|
|
|
|
|
|
try {
|
2026-06-03 17:37:35 +09:00
|
|
|
|
const blob = await imageToBlob(image)
|
2026-07-05 14:22:07 +09:00
|
|
|
|
// 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 + 규칙 파서
|
2026-06-03 17:37:35 +09:00
|
|
|
|
const { text } = await accountApi.ocrReceipt(blob)
|
|
|
|
|
|
const r = parseReceiptText(text)
|
2026-06-03 16:50:54 +09:00
|
|
|
|
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
|
2026-06-03 16:42:07 +09:00
|
|
|
|
if (r.amount) form.amount = r.amount
|
|
|
|
|
|
if (r.date) form.entryDate = r.date
|
|
|
|
|
|
if (r.store && !form.memo) form.memo = r.store
|
2026-06-03 17:56:18 +09:00
|
|
|
|
// 카드 결제 영수증이면 등록된 카드 자동 선택 (카드사 매칭)
|
|
|
|
|
|
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 }
|
2026-06-03 16:42:07 +09:00
|
|
|
|
if (!r.amount && !r.date) {
|
|
|
|
|
|
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
|
|
|
|
|
|
}
|
2026-06-03 17:37:35 +09:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
formError.value = e.response?.data?.message || '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
|
2026-06-03 16:42:07 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
ocrRunning.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
// 계좌/카드
|
|
|
|
|
|
async function loadWallets() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wallets.value = await accountApi.wallets()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
wallets.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 22:10:23 +09:00
|
|
|
|
// 계좌 종류(콤보) → 해당 종류만 목록에
|
|
|
|
|
|
const WALLET_KINDS = [
|
2026-07-04 01:24:15 +09:00
|
|
|
|
{ value: 'BANK', label: '계좌' },
|
|
|
|
|
|
{ value: 'CASH', label: '현금' },
|
|
|
|
|
|
{ value: 'CARD', label: '카드' },
|
|
|
|
|
|
{ value: 'LOAN', label: '대출' },
|
|
|
|
|
|
{ value: 'MINUS', label: '마이너스' },
|
2026-06-01 22:10:23 +09:00
|
|
|
|
{ value: 'INVEST', label: '증권' },
|
|
|
|
|
|
]
|
2026-07-06 22:51:46 +09:00
|
|
|
|
// 출금/결제 계좌 종류 뱃지에서는 대출 제외 — 대출은 지출·이체의 '출처'가 아니라 상환 대상
|
2026-07-06 23:16:03 +09:00
|
|
|
|
// 출금/결제 계좌 종류: 대출은 항상 제외(상환 대상), 카드는 '지출'(카드결제)일 때만 노출.
|
|
|
|
|
|
// 수입·이체·상환은 카드/대출에서 나가지 않음.
|
|
|
|
|
|
const fromWalletKinds = computed(() =>
|
|
|
|
|
|
WALLET_KINDS.filter((k) => {
|
|
|
|
|
|
if (k.value === 'LOAN') return false
|
|
|
|
|
|
if (k.value === 'CARD') return form.type === 'EXPENSE'
|
|
|
|
|
|
return true
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
2026-07-06 23:22:30 +09:00
|
|
|
|
// 이체 입금 계좌 종류: 카드·대출은 이체가 아니라 '상환' 대상이므로 제외
|
|
|
|
|
|
const toWalletKinds = computed(() =>
|
|
|
|
|
|
WALLET_KINDS.filter((k) => k.value !== 'CARD' && k.value !== 'LOAN'),
|
|
|
|
|
|
)
|
2026-07-04 01:24:15 +09:00
|
|
|
|
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' },
|
|
|
|
|
|
]
|
2026-06-01 22:10:23 +09:00
|
|
|
|
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
|
|
|
|
|
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
2026-07-06 23:06:17 +09:00
|
|
|
|
// 칩 선택용 옵션(계좌 선택을 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))
|
2026-06-30 22:14:02 +09:00
|
|
|
|
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
|
|
|
|
|
|
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
|
2026-06-01 22:10:23 +09:00
|
|
|
|
function walletKindOf(id) {
|
|
|
|
|
|
const w = wallets.value.find((x) => x.id === id)
|
|
|
|
|
|
return w ? w.type : ''
|
|
|
|
|
|
}
|
2026-06-03 17:56:18 +09:00
|
|
|
|
// 영수증에서 감지한 카드사명으로 등록된 카드(CARD) 찾기
|
|
|
|
|
|
function matchCardWallet(issuer) {
|
|
|
|
|
|
if (!issuer) return null
|
|
|
|
|
|
return wallets.value.find(
|
|
|
|
|
|
(w) => w.type === 'CARD' && ((w.issuer || '').includes(issuer) || (w.name || '').includes(issuer)),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
function onWalletKindChange() {
|
|
|
|
|
|
form.walletId = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
function onToWalletKindChange() {
|
|
|
|
|
|
form.toWalletId = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
// 분류(카테고리)
|
|
|
|
|
|
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))])
|
|
|
|
|
|
|
2026-07-06 23:16:03 +09:00
|
|
|
|
// 구분(수입/지출/이체) 변경 시 분류 초기화 + 새 구분에서 허용 안 되는 계좌 종류면 초기화
|
2026-06-22 23:57:19 +09:00
|
|
|
|
function onTypeChange() {
|
|
|
|
|
|
form.category = ''
|
2026-07-06 23:16:03 +09:00
|
|
|
|
if (!fromWalletKinds.value.some((k) => k.value === form.walletKind)) {
|
|
|
|
|
|
form.walletKind = ''
|
|
|
|
|
|
form.walletId = ''
|
|
|
|
|
|
}
|
2026-06-22 23:57:19 +09:00
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
// 태그 (태그 관리에 등록된 태그 선택)
|
|
|
|
|
|
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() {
|
2026-06-28 11:02:44 +09:00
|
|
|
|
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
|
|
|
|
|
if (!isPremium.value) {
|
|
|
|
|
|
tagOptions.value = []
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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' ? '' : '-'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 22:10:23 +09:00
|
|
|
|
// 일별 그룹 + 일 합계 (백엔드가 날짜 내림차순 정렬 → 순서 유지)
|
|
|
|
|
|
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})`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 22:38:22 +09:00
|
|
|
|
// ===== 날짜별 접기/펴기 (기본 접힘, 오늘만 펼침) =====
|
|
|
|
|
|
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 })
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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
|
2026-06-03 18:51:43 +09:00
|
|
|
|
editingPending.value = false
|
2026-06-30 21:37:24 +09:00
|
|
|
|
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 })
|
2026-07-05 14:52:00 +09:00
|
|
|
|
rateUpdatedAt.value = null
|
2026-05-31 15:42:52 +09:00
|
|
|
|
selectedTagIds.value = []
|
|
|
|
|
|
formError.value = null
|
2026-06-27 15:08:59 +09:00
|
|
|
|
formInfo.value = ''
|
2026-05-31 15:42:52 +09:00
|
|
|
|
formOpen.value = true
|
2026-06-27 14:50:10 +09:00
|
|
|
|
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-07-05 14:09:28 +09:00
|
|
|
|
// 내역 화면에서 바로 빠른입력: 추가 폼을 준비해 열고, 그 위에 빠른입력 모달을 띄운다.
|
|
|
|
|
|
// 분석·채우기 후 빠른입력 모달은 닫히고, 채워진 추가 폼이 남아 확인·저장.
|
|
|
|
|
|
function openQuickInput() {
|
|
|
|
|
|
openCreate()
|
|
|
|
|
|
openPasteModal()
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
function openEdit(e) {
|
|
|
|
|
|
editId.value = e.id
|
2026-06-03 18:51:43 +09:00
|
|
|
|
editingPending.value = !!e.pending
|
2026-05-31 15:42:52 +09:00
|
|
|
|
Object.assign(form, {
|
|
|
|
|
|
entryDate: e.entryDate,
|
|
|
|
|
|
type: e.type,
|
|
|
|
|
|
category: e.category || '',
|
|
|
|
|
|
amount: e.amount,
|
|
|
|
|
|
memo: e.memo || '',
|
2026-06-09 22:50:23 +09:00
|
|
|
|
// 알림 자동인식(pending): 매칭 실패 시 수입(입금)=은행, 지출(카드결제)=카드로 기본 선택(현금 X)
|
2026-06-06 00:16:50 +09:00
|
|
|
|
walletKind: e.walletId
|
|
|
|
|
|
? walletKindOf(e.walletId)
|
2026-06-09 22:50:23 +09:00
|
|
|
|
: (e.pending ? (e.type === 'INCOME' ? 'BANK' : 'CARD') : (e.type === 'TRANSFER' ? '' : 'CASH')),
|
2026-05-31 15:42:52 +09:00
|
|
|
|
walletId: e.walletId || '',
|
2026-06-01 22:10:23 +09:00
|
|
|
|
toWalletKind: walletKindOf(e.toWalletId),
|
2026-05-31 15:42:52 +09:00
|
|
|
|
toWalletId: e.toWalletId || '',
|
2026-06-12 22:06:16 +09:00
|
|
|
|
principal: null,
|
|
|
|
|
|
interest: null,
|
2026-06-12 22:52:34 +09:00
|
|
|
|
annualFee: null,
|
2026-06-03 16:42:07 +09:00
|
|
|
|
installmentMonths: e.installmentMonths || '',
|
2026-06-30 21:37:24 +09:00
|
|
|
|
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,
|
2026-05-31 15:42:52 +09:00
|
|
|
|
})
|
|
|
|
|
|
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
|
|
|
|
|
const nameToId = {}
|
|
|
|
|
|
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
|
|
|
|
|
|
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
2026-07-05 14:52:00 +09:00
|
|
|
|
rateUpdatedAt.value = null
|
2026-06-27 14:50:10 +09:00
|
|
|
|
pasteDetected.value = null
|
2026-06-27 15:31:20 +09:00
|
|
|
|
pasteModalOpen.value = false
|
|
|
|
|
|
pasteText.value = ''
|
2026-05-31 15:42:52 +09:00
|
|
|
|
formError.value = null
|
2026-06-27 15:08:59 +09:00
|
|
|
|
formInfo.value = ''
|
2026-05-31 15:42:52 +09:00
|
|
|
|
formOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submit() {
|
|
|
|
|
|
formError.value = null
|
|
|
|
|
|
if (!form.entryDate) {
|
|
|
|
|
|
formError.value = '거래일을 입력하세요.'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-12 22:52:34 +09:00
|
|
|
|
// 상환/납부: 원금=이체, 이자·연회비=지출 자동 분리
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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
|
2026-06-12 22:52:34 +09:00
|
|
|
|
const annualFee = repayTargetIsCard.value ? (Number(form.annualFee) || 0) : 0
|
|
|
|
|
|
if (principal <= 0 && interest <= 0 && annualFee <= 0) {
|
|
|
|
|
|
formError.value = '원금·이자·연회비 중 하나는 입력하세요.'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
2026-06-12 22:52:34 +09:00
|
|
|
|
// 상환은 원금=이체 / 이자·연회비=지출로 나눠 저장된다. 수정 시에는 기존 1건을
|
|
|
|
|
|
// 상환 N건으로 다시 만든다(상환 생성 성공 후 원본 삭제 — 중간 실패 시 원본 보존).
|
2026-05-31 15:42:52 +09:00
|
|
|
|
await accountApi.repayment({
|
|
|
|
|
|
entryDate: form.entryDate,
|
|
|
|
|
|
fromWalletId: form.walletId,
|
|
|
|
|
|
targetWalletId: form.toWalletId,
|
|
|
|
|
|
principal,
|
|
|
|
|
|
interest,
|
2026-06-12 22:52:34 +09:00
|
|
|
|
annualFee,
|
2026-05-31 15:42:52 +09:00
|
|
|
|
memo: form.memo || null,
|
|
|
|
|
|
})
|
2026-06-12 22:06:16 +09:00
|
|
|
|
if (editId.value) {
|
|
|
|
|
|
await accountApi.remove(editId.value)
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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
|
2026-06-30 21:37:24 +09:00
|
|
|
|
const foreign = isForeign.value
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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,
|
2026-06-03 16:42:07 +09:00
|
|
|
|
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
|
2026-05-31 15:42:52 +09:00
|
|
|
|
tagIds: selectedTagIds.value,
|
2026-06-30 21:37:24 +09:00
|
|
|
|
// 외화 결제: amount 는 환산 원화, 원본은 별도 보존
|
|
|
|
|
|
currency: foreign ? form.currency : null,
|
|
|
|
|
|
originalAmount: foreign ? Number(form.foreignAmount) : null,
|
|
|
|
|
|
exchangeRate: foreign ? Number(form.rate) : null,
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-06-03 18:51:43 +09:00
|
|
|
|
if (editId.value) {
|
|
|
|
|
|
await accountApi.update(editId.value, payload)
|
|
|
|
|
|
// 미확인(카드 알림) 건이면 수정 저장 후 확정 처리
|
|
|
|
|
|
if (editingPending.value) {
|
|
|
|
|
|
await accountApi.confirmEntry(editId.value, {})
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await accountApi.create(payload)
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
formOpen.value = false
|
2026-06-28 17:42:00 +09:00
|
|
|
|
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
|
|
|
|
|
|
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
|
2026-05-31 15:42:52 +09:00
|
|
|
|
await load()
|
2026-06-03 18:51:43 +09:00
|
|
|
|
await loadPendingCount()
|
2026-05-31 15:42:52 +09:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function remove(e) {
|
2026-06-24 21:57:50 +09:00
|
|
|
|
if (!(await dialog.confirm('이 내역을 삭제하시겠습니까?', { title: '내역 삭제', danger: true }))) return
|
2026-05-31 15:42:52 +09:00
|
|
|
|
try {
|
|
|
|
|
|
await accountApi.remove(e.id)
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert(err.response?.data?.message || '삭제에 실패했습니다.')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 21:02:09 +09:00
|
|
|
|
// 고정지출 등록 확인 모달 상태 (네이티브 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() {
|
2026-06-22 22:38:22 +09:00
|
|
|
|
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')}`
|
2026-06-23 21:02:09 +09:00
|
|
|
|
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
|
2026-06-22 22:38:22 +09:00
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
2026-06-23 21:02:09 +09:00
|
|
|
|
await accountApi.createRecurring(recurConfirm.payload)
|
|
|
|
|
|
recurConfirm.open = false
|
2026-06-22 22:38:22 +09:00
|
|
|
|
formOpen.value = false
|
2026-06-23 21:02:09 +09:00
|
|
|
|
flash('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
|
2026-06-22 22:38:22 +09:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.'
|
2026-06-23 21:02:09 +09:00
|
|
|
|
recurConfirm.open = false
|
2026-06-22 22:38:22 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 14:50:10 +09:00
|
|
|
|
// ===== 자주 쓰는 내역(빠른 등록 템플릿) =====
|
|
|
|
|
|
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
|
2026-06-27 23:43:57 +09:00
|
|
|
|
// 1) 등록 — 이 단계 실패만 '등록 실패'로 처리
|
2026-06-27 14:50:10 +09:00
|
|
|
|
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 || '빠른 등록에 실패했습니다.')
|
2026-06-27 23:43:57 +09:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2) 등록 성공 안내 + 목록 갱신(갱신 실패는 등록 성공을 가리지 않게 분리)
|
|
|
|
|
|
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await load()
|
|
|
|
|
|
await loadPendingCount()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* 목록 갱신 실패는 무시 — 다음 진입/새로고침 시 반영 */
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-06-27 15:08:59 +09:00
|
|
|
|
formInfo.value = ''
|
2026-06-27 14:50:10 +09:00
|
|
|
|
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()
|
2026-06-27 15:08:59 +09:00
|
|
|
|
formError.value = null
|
|
|
|
|
|
formInfo.value = '자주 쓰는 내역으로 저장했습니다.'
|
2026-06-27 14:50:10 +09:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 15:31:20 +09:00
|
|
|
|
// ===== 문자/푸시 붙여넣기 → 파싱 자동채움 (모달) =====
|
|
|
|
|
|
const pasteDetected = ref(null) // 자동감지 결과(버튼 배지 + 모달 배너)
|
|
|
|
|
|
const pasteModalOpen = ref(false) // 붙여넣기 모달
|
2026-06-27 14:50:10 +09:00
|
|
|
|
const pasteText = ref('')
|
2026-06-27 15:31:20 +09:00
|
|
|
|
const pasteError = ref('')
|
2026-06-27 14:50:10 +09:00
|
|
|
|
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
|
2026-07-06 22:25:29 +09:00
|
|
|
|
// 계좌 매칭: 출금(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) {
|
2026-07-06 22:36:15 +09:00
|
|
|
|
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 감시자가 이자/원금을 자동 채움
|
|
|
|
|
|
})
|
2026-07-06 22:16:52 +09:00
|
|
|
|
}
|
2026-06-27 14:50:10 +09:00
|
|
|
|
pasteDetected.value = null
|
2026-06-27 15:31:20 +09:00
|
|
|
|
pasteModalOpen.value = false
|
2026-06-27 14:50:10 +09:00
|
|
|
|
pasteText.value = ''
|
2026-06-27 15:31:20 +09:00
|
|
|
|
pasteError.value = ''
|
2026-07-05 14:09:28 +09:00
|
|
|
|
formOpen.value = true // 내역 화면에서 바로 빠른입력한 경우에도 채워진 추가 폼이 보이도록 보장
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
|
|
|
|
|
async function parseAndApply(text, { silent } = {}) {
|
|
|
|
|
|
const t = (text || '').trim()
|
|
|
|
|
|
if (!t) return false
|
|
|
|
|
|
try {
|
2026-07-05 14:09:28 +09:00
|
|
|
|
// 사용자의 실제 분류·계좌 이름을 함께 보내 AI가 그 중에서 고르게 함
|
|
|
|
|
|
const p = await accountApi.parseText({
|
|
|
|
|
|
text: t,
|
|
|
|
|
|
categoryHints: allCategoryNames.value,
|
|
|
|
|
|
walletHints: wallets.value.map((w) => w.name),
|
|
|
|
|
|
})
|
2026-06-27 14:50:10 +09:00
|
|
|
|
if (p && p.recognized) {
|
|
|
|
|
|
if (silent) pasteDetected.value = p
|
|
|
|
|
|
else applyParsed(p)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-07-05 14:09:28 +09:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 네트워크 오류는 조용히 폴백하되, 예외는 콘솔에 남겨 원인 추적이 가능하게 한다
|
|
|
|
|
|
console.error('[parseAndApply] 실패:', e)
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-07-05 15:53:24 +09:00
|
|
|
|
// 추가 팝업 열 때: 클립보드 자동 읽기 제거(iOS 는 clipboard.readText() 마다 '붙여넣기' 시스템 토스트를 띄움).
|
|
|
|
|
|
// 배지 표시용 감지도 하지 않는다. 붙여넣기는 사용자가 모달 안에서 직접 한다.
|
|
|
|
|
|
function tryClipboardOnOpen() {
|
2026-06-27 14:50:10 +09:00
|
|
|
|
pasteDetected.value = null
|
|
|
|
|
|
}
|
2026-07-05 15:53:24 +09:00
|
|
|
|
// '빠른 입력' 버튼 → 모달 열기(자동 클립보드 읽기 없음). 사용자가 자연어 입력 또는 직접 붙여넣기.
|
|
|
|
|
|
function openPasteModal() {
|
2026-06-27 15:31:20 +09:00
|
|
|
|
pasteError.value = ''
|
|
|
|
|
|
pasteModalOpen.value = true
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
2026-06-27 15:31:20 +09:00
|
|
|
|
// 모달에서 분석·채우기
|
2026-06-27 14:50:10 +09:00
|
|
|
|
async function analyzePasted() {
|
2026-06-27 15:31:20 +09:00
|
|
|
|
pasteError.value = ''
|
2026-06-27 14:50:10 +09:00
|
|
|
|
const ok = await parseAndApply(pasteText.value, { silent: false })
|
2026-06-27 15:31:20 +09:00
|
|
|
|
if (!ok) pasteError.value = '거래 내용을 인식하지 못했습니다. 금액과 승인/입금 등이 포함됐는지 확인하세요.'
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
2026-07-07 21:22:45 +09:00
|
|
|
|
// 고정지출은 무료도 2건까지 사용 가능 → 모든 로그인 사용자에 대해 실행
|
|
|
|
|
|
try {
|
|
|
|
|
|
await accountApi.runRecurrings()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
load()
|
|
|
|
|
|
loadTagOptions()
|
|
|
|
|
|
loadWallets()
|
|
|
|
|
|
loadCategories()
|
2026-06-03 18:51:43 +09:00
|
|
|
|
loadPendingCount()
|
2026-06-27 14:50:10 +09:00
|
|
|
|
loadQuick()
|
2026-06-03 18:55:48 +09:00
|
|
|
|
checkNotifPermission()
|
2026-05-31 15:42:52 +09:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="account">
|
2026-06-27 23:18:42 +09:00
|
|
|
|
<header v-if="pendingCount" class="account-head">
|
|
|
|
|
|
<span class="pending-count">확인 필요 {{ pendingCount }}건</span>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</header>
|
2026-06-23 21:02:09 +09:00
|
|
|
|
<Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-27 14:50:10 +09:00
|
|
|
|
<!-- 자주 쓰는 내역(빠른 등록) — 칩 탭 시 오늘 날짜로 즉시 등록 -->
|
|
|
|
|
|
<div v-if="quickEntries.length" class="quick-bar">
|
2026-06-27 23:43:57 +09:00
|
|
|
|
<span class="quick-bar-label">⭐ 자주 쓰는 내역</span>
|
2026-06-27 14:50:10 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-06-03 18:55:48 +09:00
|
|
|
|
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 시) -->
|
2026-06-28 11:02:44 +09:00
|
|
|
|
<div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
|
2026-06-03 18:55:48 +09:00
|
|
|
|
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
|
|
|
|
|
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<div class="month-nav">
|
2026-06-27 23:35:24 +09:00
|
|
|
|
<span class="mn-spacer"></span>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
|
|
|
|
|
<span class="period">{{ periodLabel }}</span>
|
|
|
|
|
|
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
|
2026-06-27 23:35:24 +09:00
|
|
|
|
<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>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-07-05 14:09:28 +09:00
|
|
|
|
<!-- 자연어 빠른 입력 진입점(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>
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<p v-if="error" class="msg error">{{ error }}</p>
|
|
|
|
|
|
<p v-if="loading" class="msg">불러오는 중...</p>
|
|
|
|
|
|
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<div v-else-if="entries.length" class="day-list">
|
2026-06-22 22:38:22 +09:00
|
|
|
|
<div class="list-tools">
|
|
|
|
|
|
<button type="button" class="toggle-all" @click="toggleAll">{{ allExpanded ? '모두 접기 ▴' : '모두 펴기 ▾' }}</button>
|
|
|
|
|
|
</div>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<section v-for="g in entriesByDay" :key="g.date" class="day-group">
|
2026-06-22 22:38:22 +09:00
|
|
|
|
<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>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<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>
|
2026-06-22 22:38:22 +09:00
|
|
|
|
<ul v-show="isExpanded(g.date)" class="day-items">
|
2026-06-03 18:51:43 +09:00
|
|
|
|
<li v-for="e in g.items" :key="e.id" class="entry-item" :class="{ 'is-pending': e.pending }">
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<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>
|
2026-06-03 18:51:43 +09:00
|
|
|
|
<span v-if="e.pending" class="ei-pending">확인필요</span>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<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">
|
2026-06-03 18:51:43 +09:00
|
|
|
|
<button v-if="e.pending" type="button" class="confirm-btn" title="확인(확정)" @click="confirmRow(e)">확인</button>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
|
|
|
|
|
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-06-30 21:37:24 +09:00
|
|
|
|
<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>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · 월 {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<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>
|
2026-06-29 08:26:02 +09:00
|
|
|
|
<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>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
<!-- 추가/수정 모달 -->
|
|
|
|
|
|
<Teleport to="body">
|
2026-07-04 01:24:15 +09:00
|
|
|
|
<Transition name="slide-up">
|
2026-06-30 22:06:02 +09:00
|
|
|
|
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
|
|
|
|
|
|
<div class="modal entry-modal" role="dialog" aria-modal="true">
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<button class="close" type="button" @click="formOpen = false">×</button>
|
|
|
|
|
|
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<form class="entry-form" @submit.prevent="submit">
|
2026-06-27 15:31:20 +09:00
|
|
|
|
<!-- 영수증 스캔 · 문자/푸시 (같은 행 상단) -->
|
2026-06-03 16:42:07 +09:00
|
|
|
|
<div v-if="!isRepayment" class="receipt-box">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref="receiptInput" type="file" accept="image/*"
|
2026-06-03 18:07:01 +09:00
|
|
|
|
:capture="webCapture ? 'environment' : undefined"
|
2026-06-03 16:42:07 +09:00
|
|
|
|
class="receipt-input" @change="onReceiptFile"
|
|
|
|
|
|
/>
|
2026-06-27 15:31:20 +09:00
|
|
|
|
<div class="rb-actions">
|
|
|
|
|
|
<button
|
2026-06-28 11:02:44 +09:00
|
|
|
|
v-if="isPremium"
|
2026-06-27 15:31:20 +09:00
|
|
|
|
type="button" class="receipt-btn"
|
|
|
|
|
|
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
|
|
|
|
|
>📷 영수증 스캔</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button" class="paste-btn" :class="{ detected: pasteDetected }"
|
|
|
|
|
|
:disabled="submitting" @click="openPasteModal"
|
2026-07-05 11:50:39 +09:00
|
|
|
|
>✨ 빠른 입력<span v-if="pasteDetected" class="pb-dot">●</span></button>
|
2026-06-27 15:31:20 +09:00
|
|
|
|
</div>
|
2026-06-03 18:01:51 +09:00
|
|
|
|
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
<span v-else-if="ocrResult" class="receipt-status ok">
|
2026-07-05 14:22:07 +09:00
|
|
|
|
<template v-if="ocrResult.ai">✨ </template>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
<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>
|
2026-07-05 14:22:07 +09:00
|
|
|
|
<template v-if="ocrResult.category"> · {{ ocrResult.category }}</template>
|
2026-06-03 17:56:18 +09:00
|
|
|
|
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
자동 입력됨
|
|
|
|
|
|
</span>
|
2026-07-05 14:22:07 +09:00
|
|
|
|
<span v-else class="receipt-hint">사진에서 금액·날짜·상호·분류를 자동 입력</span>
|
2026-06-03 18:01:51 +09:00
|
|
|
|
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
|
2026-07-04 01:24:15 +09:00
|
|
|
|
<!-- 구분 배지 -->
|
2026-06-04 05:01:33 +09:00
|
|
|
|
<div class="field">
|
2026-07-04 01:24:15 +09:00
|
|
|
|
<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">
|
2026-07-06 23:16:03 +09:00
|
|
|
|
<button v-for="k in fromWalletKinds" :key="k.value" type="button"
|
2026-07-04 01:24:15 +09:00
|
|
|
|
class="chip" :class="{ active: form.walletKind === k.value }"
|
|
|
|
|
|
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
|
|
|
|
|
|
{{ k.label }}
|
|
|
|
|
|
</button>
|
2026-06-04 05:01:33 +09:00
|
|
|
|
</div>
|
2026-07-06 23:06:17 +09:00
|
|
|
|
<ChipSelect
|
|
|
|
|
|
v-if="form.walletKind"
|
|
|
|
|
|
v-model="form.walletId"
|
|
|
|
|
|
:options="fromWalletOptions"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
:empty-text="`등록된 ${walletKindLabel}이(가) 없습니다`"
|
|
|
|
|
|
/>
|
2026-06-04 05:01:33 +09:00
|
|
|
|
</div>
|
2026-06-03 16:42:07 +09:00
|
|
|
|
<!-- 카드 지출: 할부 개월수 (일시불 또는 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>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
<template v-if="form.type === 'TRANSFER'">
|
2026-06-04 05:01:33 +09:00
|
|
|
|
<div class="field">
|
2026-07-04 01:24:15 +09:00
|
|
|
|
<span class="field-label">입금 종류</span>
|
|
|
|
|
|
<div class="wallet-chips">
|
2026-07-06 23:22:30 +09:00
|
|
|
|
<button v-for="k in toWalletKinds" :key="k.value" type="button"
|
2026-07-04 01:24:15 +09:00
|
|
|
|
class="chip" :class="{ active: form.toWalletKind === k.value }"
|
|
|
|
|
|
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
|
|
|
|
|
|
{{ k.label }}
|
|
|
|
|
|
</button>
|
2026-06-04 05:01:33 +09:00
|
|
|
|
</div>
|
2026-07-06 23:06:17 +09:00
|
|
|
|
<ChipSelect
|
|
|
|
|
|
v-if="form.toWalletKind"
|
|
|
|
|
|
v-model="form.toWalletId"
|
|
|
|
|
|
:options="toWalletOptions"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
empty-text="등록된 계좌가 없습니다"
|
|
|
|
|
|
/>
|
2026-06-04 05:01:33 +09:00
|
|
|
|
</div>
|
2026-06-01 22:10:23 +09:00
|
|
|
|
</template>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-12 22:52:34 +09:00
|
|
|
|
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 (+카드면 연회비). 원금=이체·이자/연회비=지출 -->
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<template v-if="isRepayment">
|
2026-07-06 23:06:17 +09:00
|
|
|
|
<div class="field">
|
|
|
|
|
|
<span class="field-label">대상(대출/카드)</span>
|
|
|
|
|
|
<ChipSelect
|
|
|
|
|
|
v-model="form.toWalletId"
|
|
|
|
|
|
:options="repayTargetOptions"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
empty-text="등록된 대출/카드가 없습니다"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-02 00:31:50 +09:00
|
|
|
|
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
|
2026-07-02 00:02:10 +09:00
|
|
|
|
<template v-if="repayTargetLoanWallet">
|
2026-07-02 00:31:50 +09:00
|
|
|
|
<!-- 원리금균등: 납입금액 직접 입력 -->
|
|
|
|
|
|
<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">
|
2026-07-02 00:02:10 +09:00
|
|
|
|
<span>이자 <b>{{ (form.interest || 0).toLocaleString('ko-KR') }}원</b></span>
|
2026-07-02 00:31:50 +09:00
|
|
|
|
<span>원금 <b>{{ (form.principal || 0).toLocaleString('ko-KR') }}원</b></span>
|
2026-07-02 00:02:10 +09:00
|
|
|
|
</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>
|
2026-06-12 22:52:34 +09:00
|
|
|
|
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
2026-07-04 01:24:15 +09:00
|
|
|
|
<CategoryPicker
|
|
|
|
|
|
v-model="form.category"
|
|
|
|
|
|
:type="form.type"
|
|
|
|
|
|
:categories="categories"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
@category-added="loadCategories"
|
|
|
|
|
|
/>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</label>
|
2026-06-30 21:37:24 +09:00
|
|
|
|
<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">
|
2026-06-30 21:56:04 +09:00
|
|
|
|
<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>
|
2026-07-05 14:52:00 +09:00
|
|
|
|
<span v-if="rateAgeLabel" class="fx-age">{{ rateAgeLabel }}</span>
|
2026-06-30 21:56:04 +09:00
|
|
|
|
</div>
|
2026-06-30 21:37:24 +09:00
|
|
|
|
<span class="fx-krw">= ₩{{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
|
|
|
|
|
|
</div>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
|
|
|
|
|
|
2026-06-28 11:02:44 +09:00
|
|
|
|
<div v-if="!isRepayment && isPremium" class="tag-field">
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<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>
|
2026-06-27 15:08:59 +09:00
|
|
|
|
<p v-if="formInfo" class="msg ok">{{ formInfo }}</p>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
2026-06-22 22:38:22 +09:00
|
|
|
|
<button
|
2026-06-28 11:24:59 +09:00
|
|
|
|
v-if="isPremium && editId && form.type !== 'REPAYMENT'"
|
2026-06-22 22:38:22 +09:00
|
|
|
|
type="button"
|
|
|
|
|
|
class="to-recurring"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
@click="registerAsRecurring"
|
|
|
|
|
|
>🔁 이 내역을 고정지출로 등록</button>
|
|
|
|
|
|
|
2026-06-27 14:50:10 +09:00
|
|
|
|
<button
|
2026-06-27 14:59:09 +09:00
|
|
|
|
v-if="editId && (form.type === 'INCOME' || form.type === 'EXPENSE')"
|
2026-06-27 14:50:10 +09:00
|
|
|
|
type="button"
|
|
|
|
|
|
class="to-recurring to-quick"
|
|
|
|
|
|
:disabled="submitting"
|
|
|
|
|
|
@click="saveAsQuick"
|
|
|
|
|
|
>⭐ 자주 쓰는 내역으로 저장</button>
|
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<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>
|
2026-06-03 18:07:01 +09:00
|
|
|
|
|
|
|
|
|
|
<!-- 영수증 등록 방식 선택 (카메라/갤러리) -->
|
|
|
|
|
|
<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>
|
2026-06-23 21:02:09 +09:00
|
|
|
|
|
|
|
|
|
|
<!-- 고정지출 등록 확인 (네이티브 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>
|
2026-06-27 15:19:13 +09:00
|
|
|
|
|
|
|
|
|
|
<!-- 자주 쓰는 내역 불러오기 (모달 피커) -->
|
2026-06-27 15:31:20 +09:00
|
|
|
|
<!-- 문자/푸시 붙여넣기 (모달) -->
|
|
|
|
|
|
<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>
|
2026-07-05 11:24:14 +09:00
|
|
|
|
<h2>빠른 입력</h2>
|
|
|
|
|
|
<p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p>
|
|
|
|
|
|
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원 또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27"></textarea>
|
2026-07-05 14:09:28 +09:00
|
|
|
|
<div v-if="pasteDetected && pasteDetected.amount" class="paste-detected">
|
2026-06-27 15:31:20 +09:00
|
|
|
|
감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-if="pasteError" class="msg error">{{ pasteError }}</p>
|
2026-07-05 14:09:28 +09:00
|
|
|
|
<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>
|
2026-06-27 15:31:20 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</Teleport>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</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;
|
|
|
|
|
|
}
|
2026-06-27 23:35:24 +09:00
|
|
|
|
/* 좌측 spacer + 우측 액션을 같은 flex:1 로 둬서 가운데 네비를 정확히 중앙 유지 */
|
|
|
|
|
|
.mn-spacer {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mn-actions {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-07-05 14:09:28 +09:00
|
|
|
|
/* 자연어 빠른 입력 바 */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
/* 일별 그룹 목록 */
|
|
|
|
|
|
.day-group {
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
2026-06-22 22:38:22 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.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;
|
2026-06-22 22:38:22 +09:00
|
|
|
|
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;
|
2026-06-01 22:10:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
.day-date {
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
2026-06-22 22:38:22 +09:00
|
|
|
|
.day-count {
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.day-sums {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.7rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
font-weight: 600;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.day-sums .income {
|
|
|
|
|
|
color: #2e7d32;
|
|
|
|
|
|
}
|
|
|
|
|
|
.day-sums .expense {
|
|
|
|
|
|
color: #c0392b;
|
|
|
|
|
|
}
|
|
|
|
|
|
.day-items {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.entry-item {
|
|
|
|
|
|
padding: 0.5rem 0.1rem;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
|
}
|
2026-06-03 18:51:43 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-03 18:55:48 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-03 18:51:43 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-line1 {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-cat {
|
|
|
|
|
|
font-weight: 500;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-wallet {
|
|
|
|
|
|
font-size: 0.74rem;
|
|
|
|
|
|
padding: 0.05rem 0.35rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
opacity: 0.85;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
white-space: nowrap;
|
2026-06-01 22:10:23 +09:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
max-width: 45%;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-amount {
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ei-amount.income {
|
2026-05-31 15:42:52 +09:00
|
|
|
|
color: #2e7d32;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-amount.expense {
|
2026-05-31 15:42:52 +09:00
|
|
|
|
color: #c0392b;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-amount.transfer {
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ei-act {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
flex-shrink: 0;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-line2 {
|
|
|
|
|
|
margin-top: 0.25rem;
|
|
|
|
|
|
padding-left: 1.1rem;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
font-size: 0.8rem;
|
2026-06-01 22:10:23 +09:00
|
|
|
|
opacity: 0.85;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-29 08:26:02 +09:00
|
|
|
|
/* 신규 유저 빈 상태 */
|
|
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.msg.error {
|
|
|
|
|
|
color: #c0392b;
|
|
|
|
|
|
}
|
2026-06-27 15:08:59 +09:00
|
|
|
|
.msg.ok {
|
|
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
color: hsla(160, 100%, 30%, 1);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
/* 모달 */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-23 21:02:09 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-30 22:06:02 +09:00
|
|
|
|
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
/* 전체화면 내역 모달 슬라이드업 */
|
|
|
|
|
|
.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%);
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.modal {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 360px;
|
2026-07-07 00:03:48 +09:00
|
|
|
|
/* backdrop 이 flex 라 모달(flex 아이템) 기본 min-width:auto 가 내용물만큼 모달을 뷰포트보다 넓힘 → 0 으로 고정 */
|
|
|
|
|
|
min-width: 0;
|
2026-06-04 05:01:33 +09:00
|
|
|
|
/* 폼이 길어도 모달 안에서 스크롤되도록 + 하단 소프트키/제스처바 안전영역 확보 */
|
|
|
|
|
|
max-height: calc(100vh - 2rem);
|
|
|
|
|
|
overflow-y: auto;
|
2026-07-06 23:50:26 +09:00
|
|
|
|
overflow-x: hidden; /* 내부 요소가 가로로 넘쳐도 페이지가 밀리지 않게(넓은 그리드·입력행 방지) */
|
2026-06-04 05:01:33 +09:00
|
|
|
|
padding: 1.75rem 1.5rem calc(1.5rem + env(safe-area-inset-bottom));
|
2026-05-31 15:42:52 +09:00
|
|
|
|
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;
|
2026-07-04 22:42:37 +09:00
|
|
|
|
z-index: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 전체화면 모달(entry)만: 닫기 버튼을 상태바/노치 아래로 — 중앙 모달엔 적용 안 함 */
|
|
|
|
|
|
.modal.entry-modal .close {
|
|
|
|
|
|
top: calc(0.5rem + env(safe-area-inset-top));
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
.modal h2 {
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.entry-form {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
|
.receipt-box {
|
|
|
|
|
|
display: flex;
|
2026-06-27 15:31:20 +09:00
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start;
|
2026-06-03 16:42:07 +09:00
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
padding: 0.5rem 0.6rem;
|
|
|
|
|
|
border: 1px dashed var(--color-border);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
}
|
2026-06-27 15:31:20 +09:00
|
|
|
|
.rb-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-06-03 18:01:51 +09:00
|
|
|
|
/* 인식 중 무한 로딩 바 */
|
|
|
|
|
|
.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%; }
|
|
|
|
|
|
}
|
2026-06-03 18:07:01 +09:00
|
|
|
|
/* 영수증 등록 방식 선택 팝업 */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
/* 배지형 선택 (구분 · 계좌 종류) */
|
2026-06-04 05:01:33 +09:00
|
|
|
|
.field {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.field-label {
|
|
|
|
|
|
font-size: 0.82rem;
|
2026-07-04 01:24:15 +09:00
|
|
|
|
opacity: 0.7;
|
2026-06-30 23:23:08 +09:00
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
.type-chips,
|
|
|
|
|
|
.wallet-chips {
|
2026-06-30 23:23:08 +09:00
|
|
|
|
display: flex;
|
2026-07-01 00:13:58 +09:00
|
|
|
|
flex-wrap: wrap;
|
2026-06-30 23:23:08 +09:00
|
|
|
|
gap: 0.35rem;
|
2026-07-01 00:13:58 +09:00
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
.chip {
|
|
|
|
|
|
padding: 0.28rem 0.75rem;
|
|
|
|
|
|
border: 1.5px solid var(--color-border);
|
|
|
|
|
|
border-radius: 100px;
|
2026-07-01 00:19:27 +09:00
|
|
|
|
background: var(--color-background-soft);
|
2026-06-30 23:23:08 +09:00
|
|
|
|
color: var(--color-text);
|
2026-07-04 01:24:15 +09:00
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
2026-06-30 23:23:08 +09:00
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
.chip:disabled {
|
|
|
|
|
|
opacity: 0.45;
|
|
|
|
|
|
cursor: not-allowed;
|
2026-06-30 23:23:08 +09:00
|
|
|
|
}
|
2026-07-04 01:24:15 +09:00
|
|
|
|
/* 구분 배지 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 {
|
2026-07-01 00:13:58 +09:00
|
|
|
|
border-color: hsla(160, 100%, 37%, 1);
|
2026-07-04 01:24:15 +09:00
|
|
|
|
background: hsla(160, 100%, 37%, 1);
|
|
|
|
|
|
color: #fff;
|
2026-07-01 00:32:59 +09:00
|
|
|
|
}
|
2026-07-02 00:02:10 +09:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-07-02 00:31:50 +09:00
|
|
|
|
.loan-breakdown .bd-label {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
margin-bottom: 0.1rem;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-30 21:37:24 +09:00
|
|
|
|
/* 외화 표기(목록 상세) */
|
|
|
|
|
|
.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;
|
2026-06-30 21:56:04 +09:00
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
gap: 0.6rem;
|
2026-06-30 21:37:24 +09:00
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
margin: -0.3rem 0 0.2rem;
|
|
|
|
|
|
}
|
2026-06-30 21:56:04 +09:00
|
|
|
|
.fx-rate-field {
|
2026-06-30 21:37:24 +09:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-06-30 21:56:04 +09:00
|
|
|
|
}
|
|
|
|
|
|
.fx-label {
|
2026-06-30 21:37:24 +09:00
|
|
|
|
font-size: 0.8rem;
|
2026-06-30 21:56:04 +09:00
|
|
|
|
margin-bottom: 0.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* input 과 버튼을 한 행으로 — stretch 로 버튼 높이를 input 에 맞춤 */
|
|
|
|
|
|
.fx-rate-input {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
gap: 0.4rem;
|
2026-06-30 21:37:24 +09:00
|
|
|
|
}
|
2026-06-30 21:56:04 +09:00
|
|
|
|
.fx-rate-input input {
|
2026-06-30 21:37:24 +09:00
|
|
|
|
width: 7rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.fx-auto {
|
2026-06-30 21:56:04 +09:00
|
|
|
|
padding: 0 0.7rem;
|
2026-06-30 21:37:24 +09:00
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: var(--color-background-soft);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
font-size: 0.82rem;
|
2026-06-30 21:56:04 +09:00
|
|
|
|
white-space: nowrap;
|
2026-06-30 21:37:24 +09:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.fx-krw {
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #b8860b;
|
|
|
|
|
|
font-size: 0.9rem;
|
2026-06-30 21:56:04 +09:00
|
|
|
|
padding-bottom: 0.45rem;
|
2026-06-30 21:37:24 +09:00
|
|
|
|
}
|
2026-07-05 14:52:00 +09:00
|
|
|
|
.fx-age {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: var(--color-text-soft, #999);
|
|
|
|
|
|
margin-top: 0.18rem;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-27 14:50:10 +09:00
|
|
|
|
/* 자주 쓰는 내역 칩 */
|
|
|
|
|
|
.quick-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
margin: 0.4rem 0 0.2rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
2026-06-27 23:43:57 +09:00
|
|
|
|
.quick-bar-label {
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
opacity: 0.65;
|
|
|
|
|
|
margin-right: 0.2rem;
|
|
|
|
|
|
}
|
2026-06-27 14:50:10 +09:00
|
|
|
|
.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 {
|
2026-06-27 15:31:20 +09:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
padding: 0.45rem 0.85rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
2026-06-27 14:50:10 +09:00
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: 6px;
|
2026-06-27 15:19:13 +09:00
|
|
|
|
background: var(--color-background);
|
2026-06-27 14:50:10 +09:00
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
2026-06-27 15:19:13 +09:00
|
|
|
|
white-space: nowrap;
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
2026-06-27 15:31:20 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-27 14:50:10 +09:00
|
|
|
|
.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;
|
2026-06-27 15:31:20 +09:00
|
|
|
|
margin: 0.5rem 0;
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
2026-06-27 15:31:20 +09:00
|
|
|
|
.paste-modal .pm-hint {
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
margin: 0 0 0.6rem;
|
2026-06-27 14:50:10 +09:00
|
|
|
|
}
|
2026-06-27 15:31:20 +09:00
|
|
|
|
.paste-modal .pm-text {
|
2026-06-27 14:50:10 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-05 14:09:28 +09:00
|
|
|
|
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-22 22:38:22 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-27 14:50:10 +09:00
|
|
|
|
.to-quick {
|
|
|
|
|
|
color: #b8860b;
|
|
|
|
|
|
border-color: rgba(184, 134, 11, 0.6);
|
|
|
|
|
|
}
|
2026-06-27 15:19:13 +09:00
|
|
|
|
/* 자주 쓰는 내역 불러오기 피커 모달 */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-22 22:38:22 +09:00
|
|
|
|
.to-recurring:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.buttons {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
margin-top: 0.5rem;
|
2026-06-04 05:01:33 +09:00
|
|
|
|
/* 폼이 길 때도 저장/취소 버튼이 항상 보이도록 하단 고정 */
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
bottom: calc(-1.5rem - env(safe-area-inset-bottom));
|
|
|
|
|
|
padding: 0.6rem 0 0.2rem;
|
|
|
|
|
|
background: var(--color-background);
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
.fade-enter-active,
|
|
|
|
|
|
.fade-leave-active {
|
|
|
|
|
|
transition: opacity 0.18s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.fade-enter-from,
|
|
|
|
|
|
.fade-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 22:10:23 +09:00
|
|
|
|
/* ===== 모바일 ===== */
|
2026-05-31 15:42:52 +09:00
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.account {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary {
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card {
|
|
|
|
|
|
padding: 0.6rem 0.7rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card .value {
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
2026-06-01 22:10:23 +09:00
|
|
|
|
.ei-wallet {
|
|
|
|
|
|
max-width: 38%;
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
.filter-bar .f-keyword {
|
|
|
|
|
|
flex-basis: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|