Files
sb-front/src/views/account/AccountView.vue
T
ByungCheol 63a8a71e3b
CI / build (push) Failing after 11m2s
feat: 로그인 영속화 + 내역 계좌선택 분리 + 일별 그룹 레이아웃
- 로그인 유지: Capacitor Preferences 로 토큰 영속화(앱 재실행 시 로그아웃 방지),
  시작 시 세션 복원 후 마운트
- 내역 추가: 계좌 종류(계좌/카드/대출/증권) 콤보 선택 → 해당 종류만 목록에
- 가계부 내역: 일별 그룹 + 일 수입/지출 합계, 항목은 분류·계좌·금액 / 메모·태그(2줄),
  per-row 날짜 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:10:23 +09:00

945 lines
27 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'
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 })
const isRepayment = computed(() => form.type === 'REPAYMENT')
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
const submitting = ref(false)
const formError = ref(null)
// 계좌/카드
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 : ''
}
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 })
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 || '',
})
// 태그 이름 → 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,
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.tags && e.tags.length)" class="ei-line2">
<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">
<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>
<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;
}
.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);
}
.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>