Files
sb-front/src/views/account/AccountView.vue
T
ByungCheol 537cea31b0
CI / build (push) Failing after 15m1s
feat: 영수증 OCR 상호명→메모, 카드사 감지→카드 자동선택
- 상호 추출 개선(GS25 등 숫자 포함 상호 허용, 날짜·전화·사업자·주소 제외)
- 카드 결제 영수증의 카드사 감지 → 등록된 카드(issuer/name 매칭) 자동 선택
- 매칭 실패해도 카드결제면 계좌종류를 카드로 좁혀줌
- 결과 표시에 💳카드명 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:18 +09:00

1109 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { Capacitor } from '@capacitor/core'
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
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 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, installmentMonths: '' })
const isRepayment = computed(() => form.type === 'REPAYMENT')
// 카드 지출일 때만 할부 입력 노출 (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)
// 영수증 OCR (온디바이스)
const receiptInput = ref(null)
const ocrRunning = ref(false)
const ocrResult = ref(null) // { amount, date, store }
async function pickReceipt() {
ocrResult.value = null
// 네이티브(앱): 카메라/갤러리 선택 프롬프트, 웹: 파일 선택
if (Capacitor.isNativePlatform()) {
try {
const photo = await Camera.getPhoto({
source: CameraSource.Prompt,
resultType: CameraResultType.DataUrl,
quality: 70,
correctOrientation: true,
promptLabelHeader: '영수증',
promptLabelPhoto: '갤러리에서 선택',
promptLabelPicture: '카메라로 촬영',
promptLabelCancel: '취소',
})
if (photo?.dataUrl) await runOcr(photo.dataUrl)
} catch {
// 사용자가 취소 → 무시
}
} else {
receiptInput.value?.click()
}
}
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: '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 = []
}
}
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
// 필터용 전체 분류명 (중복 제거)
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
try {
await accountApi.createCategory({ type: form.type, name })
await loadCategories()
form.category = name
cancelAddCategory()
} catch (e) {
if (e.response?.status === 409) {
// 이미 있는 분류면 그대로 선택
form.category = name
cancelAddCategory()
} else {
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
}
} finally {
catSubmitting.value = false
}
}
// 태그 (태그 관리에 등록된 태그 선택)
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() {
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})`
}
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
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' })
selectedTagIds.value = []
cancelAddCategory()
formError.value = null
formOpen.value = true
}
function openEdit(e) {
editId.value = e.id
Object.assign(form, {
entryDate: e.entryDate,
type: e.type,
category: e.category || '',
amount: e.amount,
memo: e.memo || '',
walletKind: walletKindOf(e.walletId),
walletId: e.walletId || '',
toWalletKind: walletKindOf(e.toWalletId),
toWalletId: e.toWalletId || '',
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)
cancelAddCategory()
formError.value = null
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
if (principal <= 0 && interest <= 0) {
formError.value = '원금 또는 이자를 입력하세요.'
return
}
submitting.value = true
try {
await accountApi.repayment({
entryDate: form.entryDate,
fromWalletId: form.walletId,
targetWalletId: form.toWalletId,
principal,
interest,
memo: form.memo || null,
})
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)
else await accountApi.create(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(e) {
if (!confirm('이 내역을 삭제하시겠습니까?')) return
try {
await accountApi.remove(e.id)
await load()
} catch (err) {
alert(err.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
}
load()
loadTagOptions()
loadWallets()
loadCategories()
})
</script>
<template>
<section class="account">
<header class="account-head">
<h1>가계부</h1>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</header>
<div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<IconBtn
icon="filter" title="검색·필터" class="filter-toggle"
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
/>
</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">
<section v-for="g in entriesByDay" :key="g.date" class="day-group">
<div class="day-head">
<span class="day-date">{{ dayLabel(g.date) }}</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 class="day-items">
<li v-for="e in g.items" :key="e.id" class="entry-item">
<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.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">
<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">
<!-- 영수증 OCR (온디바이스) -->
<div v-if="!isRepayment" class="receipt-box">
<input
ref="receiptInput" type="file" accept="image/*"
class="receipt-input" @change="onReceiptFile"
/>
<button
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
<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>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting">
<option value="EXPENSE">지출</option>
<option value="INCOME">수입</option>
<option value="TRANSFER">이체</option>
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
</select>
</label>
<!-- 계좌 종류 먼저 선택 해당 종류만 목록에 -->
<label>계좌 종류
<select v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange">
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
<option v-for="k in WALLET_KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
</select>
</label>
<label v-if="form.walletKind">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
<select v-model="form.walletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</label>
<!-- 카드 지출: 할부 개월수 (일시불 또는 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'">
<label>입금 종류
<select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange">
<option value="">(선택)</option>
<option v-for="k in WALLET_KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
</select>
</label>
<label v-if="form.toWalletKind">입금 계좌
<select 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>
</label>
</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>
</template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<div v-if="!addingCategory" class="cat-input">
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</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" 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>
<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>
</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;
}
.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;
}
.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;
}
.day-date {
font-size: 0.9rem;
font-weight: 700;
}
.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);
}
.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;
}
/* 모달 */
.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;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
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;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.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);
}
.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);
}
.cat-input {
display: flex;
gap: 0.4rem;
}
.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;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.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>