Files
sb-front/src/views/account/AccountWalletView.vue
T
ByungCheol c128948d39
CI / build (push) Successful in 34s
feat: 계좌 이용내역 월별 표시 — 길어지는 스크롤 해소
- 계좌(은행/카드) 펼침 시 전체 평면 리스트 대신 월 단위로 표시
  · ◀ YYYY.MM ▶ 월 네비게이션(데이터 있는 월만, 빈 달 건너뜀) + 그 달 합계
  · 기본은 내역이 있는 가장 최근 월, 월 필터는 클라이언트단(walletEntries 재사용, 백엔드 무변경)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:29:54 +09:00

899 lines
25 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 { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import InvestPortfolio from './InvestPortfolio.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const TABS = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'INVEST', label: '투자' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false)
const error = ref(null)
const activeType = ref('BANK')
const formOpen = ref(false)
const editId = ref(null)
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
const submitting = ref(false)
const formError = ref(null)
// 드롭다운(계좌별 내역) — 월별로 표시
const expandedId = ref(null)
const entriesByWallet = reactive({})
const loadingEntries = ref(false)
const entryMonth = reactive({}) // 계좌별 선택 월(YYYY-MM)
function currentYm() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function ymLabel(ym) {
return ym ? ym.replace('-', '.') : ''
}
// 데이터가 있는 월 목록(오름차순)
function monthList(w) {
const set = new Set((entriesByWallet[w.id] || []).map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
return [...set].sort()
}
function monthEntries(w) {
const ym = entryMonth[w.id]
return (entriesByWallet[w.id] || []).filter((e) => (e.entryDate || '').slice(0, 7) === ym)
}
function monthSum(w) {
return monthEntries(w).reduce((s, e) => s + effectAmount(e, w.id), 0)
}
function canPrev(w) {
return monthList(w).indexOf(entryMonth[w.id]) > 0
}
function canNext(w) {
const ms = monthList(w)
const i = ms.indexOf(entryMonth[w.id])
return i >= 0 && i < ms.length - 1
}
// 데이터가 있는 월 사이로만 이동(빈 달 건너뜀)
function shiftMonth(w, delta) {
const ms = monthList(w)
if (!ms.length) return
const i = ms.indexOf(entryMonth[w.id])
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
entryMonth[w.id] = ms[j]
}
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
// 현재 탭(activeType)의 계좌 — 드래그 정렬 대상 (백엔드가 sort_order 순 정렬)
const rows = ref([])
function syncRows() {
rows.value = wallets.value.filter((w) => w.type === activeType.value)
}
watch([wallets, activeType], syncRows, { immediate: true })
// 탭 선택 — 투자 탭이면 전체 시세를 갱신해 계좌 평가액에 반영
const refreshingInvest = ref(false)
async function selectTab(key) {
activeType.value = key
if (key === 'INVEST' && wallets.value.some((w) => w.type === 'INVEST')) {
refreshingInvest.value = true
try {
await accountApi.refreshAllPrices()
await load()
} catch {
// 시세 갱신 실패는 조용히 무시(기존 값 유지)
} finally {
refreshingInvest.value = false
}
}
}
// ===== 드래그앤드랍 순서 변경 (SortableJS, 터치 지원) =====
const listEl = ref(null)
let sortable = null
function initSortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
if (!listEl.value) return
sortable = Sortable.create(listEl.value, {
handle: '.drag-handle',
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return
const arr = rows.value
const moved = arr.splice(evt.oldIndex, 1)[0]
arr.splice(evt.newIndex, 0, moved)
persistOrder(arr.map((r) => r.id))
},
})
}
async function persistOrder(ids) {
try {
await accountApi.reorderWallets(activeType.value, ids)
await load()
} catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
await load()
}
}
async function toggleExpand(w) {
if (expandedId.value === w.id) {
expandedId.value = null
return
}
expandedId.value = w.id
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
loadingEntries.value = true
try {
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
// 기본 선택 월 = 내역이 있는 가장 최근 월(없으면 이번 달)
const ms = monthList(w)
entryMonth[w.id] = ms.length ? ms[ms.length - 1] : currentYm()
} catch {
entriesByWallet[w.id] = []
} finally {
loadingEntries.value = false
}
}
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
function effectAmount(e, walletId) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e, w) {
const m = e.memo ? ` · ${e.memo}` : ''
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
// 계좌번호 마스킹: 끝 4자리만 노출(구분자 유지), 눈 버튼으로 전체 보기 토글
const revealedAccts = ref(new Set())
function maskAccount(num) {
if (!num) return ''
const digitCount = (num.match(/\d/g) || []).length
if (digitCount <= 4) return num
const keep = digitCount - 4
let seen = 0
return num.replace(/\d/g, (d) => {
seen += 1
return seen <= keep ? '*' : d
})
}
function toggleReveal(id) {
const s = new Set(revealedAccts.value)
s.has(id) ? s.delete(id) : s.add(id)
revealedAccts.value = s
}
function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '초기 투입원금' : '대출 잔액(원금)'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() {
loading.value = true
error.value = null
try {
const [ws, nw] = await Promise.all([accountApi.wallets(), accountApi.netWorth()])
wallets.value = ws
networth.value = nw
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
type: activeType.value,
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// 부채는 음수로 저장 (갚을 금액)
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(w) {
if (!confirm(`'${w.name}' 을(를) 삭제할까요?`)) return
try {
await accountApi.removeWallet(w.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
onMounted(async () => {
await load()
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
</script>
<template>
<section class="wallet">
<header class="head">
<h1>계좌 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<!-- 순자산 요약 -->
<div class="networth">
<div class="nw-card">
<span class="label">총자산</span>
<span class="value asset">{{ won(networth.totalAssets) }}</span>
</div>
<div class="nw-card">
<span class="label">총부채</span>
<span class="value debt">{{ won(networth.totalLiabilities) }}</span>
</div>
<div class="nw-card">
<span class="label">순자산</span>
<span class="value">{{ won(networth.netWorth) }}</span>
</div>
</div>
<div class="tabbar">
<div class="tabs">
<button
v-for="t in TABS"
:key="t.key"
type="button"
:class="{ active: activeType === t.key }"
@click="selectTab(t.key)"
>{{ t.label }}</button>
</div>
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
</div>
<p v-if="refreshingInvest" class="msg refresh-note">시세 갱신 </p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && rows.length" ref="listEl" class="wallet-list">
<li v-for="w in rows" :key="w.id" :data-id="w.id" class="wallet-item">
<div class="wallet-row" @click="toggleExpand(w)">
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop></span>
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
<div class="info">
<div class="line1">
<span class="name">{{ w.name }}</span>
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div>
<span class="sub">
{{ w.issuer }}
<template v-if="w.type === 'BANK' && w.accountNumber">
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
<button
type="button" class="acct-eye"
:title="revealedAccts.has(w.id) ? '가리기' : '전체 보기'"
@click.stop="toggleReveal(w.id)"
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template>
<template v-if="w.type === 'INVEST' && w.manualValuation"> · 평가액 직접입력</template>
<template v-else-if="w.type === 'INVEST'"> · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}</template>
</span>
</div>
<div class="balance-wrap">
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
<div
v-if="w.type === 'INVEST' && w.valuationGain != null"
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
>
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</div>
</div>
<div class="actions" @click.stop>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div>
</div>
<!-- 드롭다운: 투자는 포트폴리오, 외는 계좌별 내역 -->
<div v-if="expandedId === w.id" class="entry-drop">
<div v-if="w.type === 'INVEST' && w.manualValuation" class="manual-note">
<p>평가액을 <b>직접 입력</b>하는 계좌입니다. 종목을 관리하지 않고 입력한 현재 평가액({{ won(w.currentValue) }}) 사용합니다.</p>
<p class="manual-sub">평가액을 갱신하려면 <b>수정</b>에서 현재 평가액 바꾸세요. 종목으로 자동계산하려면 평가액을 비우면 됩니다.</p>
</div>
<InvestPortfolio v-else-if="w.type === 'INVEST'" :wallet-id="w.id" @changed="load" />
<template v-else>
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
<template v-else-if="(entriesByWallet[w.id] || []).length">
<!-- 네비게이션 + 합계 -->
<div class="month-nav">
<button type="button" class="mn-btn" :disabled="!canPrev(w)" aria-label="이전 " @click="shiftMonth(w, -1)"></button>
<span class="mn-label">{{ ymLabel(entryMonth[w.id]) }}</span>
<button type="button" class="mn-btn" :disabled="!canNext(w)" aria-label="다음 " @click="shiftMonth(w, 1)"></button>
<span class="mn-sum" :class="monthSum(w) < 0 ? 'neg' : 'pos'">
합계 {{ monthSum(w) >= 0 ? '+' : '' }}{{ won(monthSum(w)) }}
</span>
</div>
<ul v-if="monthEntries(w).length" class="drop-list">
<li v-for="e in monthEntries(w)" :key="e.id" class="drop-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e, w) }}</span>
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
</span>
</li>
</ul>
<p v-else class="drop-msg"> 내역이 없습니다.</p>
</template>
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
</template>
</div>
</li>
</ul>
<p v-if="!loading && !rows.length" class="msg">등록된 항목이 없습니다.</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>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label>{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label v-if="form.type === 'CARD'">카드 종류
<select v-model="form.cardType" :disabled="submitting">
<option value="CREDIT">신용카드</option>
<option value="CHECK">체크카드</option>
</select>
</label>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액 (직접 입력)
<input v-model.number="form.currentValue" type="number" min="0" placeholder="(선택) 퇴직연금·연금처럼 종목 관리가 어려운 계좌" :disabled="submitting" />
</label>
<p class="form-hint">
증권계좌 입금은 은행투자 이체 기록하세요. 계좌를 펼치면 <b>종목·매수/매도·현재가</b> 관리할 있고, 평가금액·손익은 자동 계산됩니다.<br />
<b>퇴직연금·연금</b>처럼 종목 단위 관리가 어려우면 <b>현재 평가액</b> 주기적으로 갱신하세요. 입력하면 종목 자동계산 대신 값을 평가액으로 사용합니다.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<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>
.wallet {
max-width: 640px;
margin: 0 auto;
}
.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);
}
.networth {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.nw-card {
flex: 1;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.7rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.nw-card .label {
font-size: 0.82rem;
opacity: 0.7;
}
.nw-card .value {
font-size: 1.1rem;
font-weight: 700;
}
.nw-card .value.asset {
color: #2e7d32;
}
.nw-card .value.debt {
color: #c0392b;
}
.tabbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.tab-add {
flex-shrink: 0;
}
.tabs button {
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.wallet-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.wallet-item {
border-bottom: 1px solid var(--color-border);
}
.wallet-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
}
.drag-handle {
cursor: grab;
user-select: none;
opacity: 0.45;
font-size: 1.1rem;
padding: 0 0.15rem;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.chevron {
width: 1rem;
opacity: 0.6;
font-size: 0.8rem;
}
.entry-drop {
padding: 0.25rem 0 0.75rem 1.6rem;
}
.drop-msg {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem 0;
}
.refresh-note {
font-size: 0.82rem;
opacity: 0.6;
}
.manual-note {
padding: 0.6rem 0.2rem;
font-size: 0.85rem;
}
.manual-note p {
margin: 0 0 0.3rem;
}
.manual-note .manual-sub {
opacity: 0.6;
font-size: 0.78rem;
margin: 0;
}
.month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0 0.4rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.3rem;
}
.mn-btn {
width: 1.8rem;
height: 1.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
line-height: 1;
}
.mn-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.mn-label {
font-weight: 700;
font-size: 0.9rem;
min-width: 4.2rem;
text-align: center;
}
.mn-sum {
margin-left: auto;
font-size: 0.82rem;
font-weight: 600;
}
.mn-sum.pos {
color: #2e7d32;
}
.mn-sum.neg {
color: #c0392b;
}
.drop-list {
list-style: none;
}
.drop-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
font-size: 0.85rem;
border-top: 1px dashed var(--color-border);
}
.d-date {
width: 3rem;
opacity: 0.7;
white-space: nowrap;
}
.d-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d-amount {
white-space: nowrap;
font-weight: 600;
}
.d-amount.pos {
color: #2e7d32;
}
.d-amount.neg {
color: #c0392b;
}
.info {
flex: 1;
min-width: 0;
}
.line1 {
display: flex;
align-items: center;
gap: 0.4rem;
}
.name {
font-weight: 600;
}
.badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
.sub {
font-size: 0.82rem;
opacity: 0.7;
}
.acct-eye {
border: 0;
background: transparent;
padding: 0 0.15rem;
font-size: 0.78rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
}
.acct-eye:hover {
opacity: 1;
}
.balance-wrap {
text-align: right;
white-space: nowrap;
}
.balance {
font-weight: 700;
white-space: nowrap;
}
.balance.neg {
color: #c0392b;
}
.gain {
font-size: 0.76rem;
font-weight: 600;
}
.gain.pos {
color: #2e7d32;
}
.gain.neg {
color: #c0392b;
}
.form-hint {
font-size: 0.76rem;
opacity: 0.65;
margin: -0.2rem 0 0.1rem;
}
.actions {
display: flex;
gap: 0.4rem;
}
.actions button {
padding: 0.2rem 0.55rem;
font-size: 0.82rem;
}
.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;
}
.wallet-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.wallet-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.wallet-form input,
.wallet-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);
}
.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) {
.wallet {
max-width: 100%;
}
.networth {
gap: 0.5rem;
}
.nw-card {
padding: 0.5rem 0.6rem;
}
.nw-card .value {
font-size: 0.95rem;
}
.tabs button {
padding: 0.55rem 0.6rem;
font-size: 0.9rem;
}
/* 한 줄 유지: 이름(축소) · 잔액 · 아이콘 */
.wallet-row {
column-gap: 0.4rem;
}
.info {
flex: 1;
min-width: 0;
}
/* 이름은 한 줄(말줄임), sub(예수금·주식 등)는 자연 줄바꿈 — 손익과 겹치지 않게 */
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
word-break: break-all;
}
.wallet-row {
align-items: flex-start;
}
.balance {
font-size: 0.95rem;
}
.balance-wrap {
flex-shrink: 0;
padding-left: 0.3rem;
}
.actions {
flex-shrink: 0;
gap: 0.3rem;
}
}
</style>