Files
sb-front/src/views/account/AccountView.vue
T
ByungCheol e8d3549f02
CI / build (push) Failing after 13m7s
feat: 계정삭제 안내 페이지 + 카드알림(알림접근) 첫 출시 비활성화
- /account-deletion 공개 페이지(웹 접근) — Play 데이터 삭제 정책 URL, 웹 안내 하단 링크
- CARD_NOTIF_ENABLED=false: 카드알림 자동인식 UI 숨김
- AndroidManifest: CardNotifListenerService 선언 주석 처리(알림접근 정책 회피)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:43:22 +09:00

2031 lines
63 KiB
Vue
Raw Blame History

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