feat: 로그인 영속화 + 내역 계좌선택 분리 + 일별 그룹 레이아웃
CI / build (push) Failing after 11m2s

- 로그인 유지: Capacitor Preferences 로 토큰 영속화(앱 재실행 시 로그아웃 방지),
  시작 시 세션 복원 후 마운트
- 내역 추가: 계좌 종류(계좌/카드/대출/증권) 콤보 선택 → 해당 종류만 목록에
- 가계부 내역: 일별 그룹 + 일 수입/지출 합계, 항목은 분류·계좌·금액 / 메모·태그(2줄),
  per-row 날짜 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-01 22:10:23 +09:00
parent 385f6c970e
commit 63a8a71e3b
7 changed files with 239 additions and 148 deletions
+179 -133
View File
@@ -40,7 +40,7 @@ function resetFilters() {
// 추가/수정 모달
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: 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)
@@ -56,6 +56,26 @@ async function loadWallets() {
}
}
// 계좌 종류(콤보) → 해당 종류만 목록에
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() {
@@ -131,11 +151,6 @@ const periodLabel = computed(() => `${year.value}년 ${String(month.value).padSt
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function formatDate(value) {
if (!value) return '-'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : `${d.getMonth() + 1}/${d.getDate()}`
}
function dotClass(type) {
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
}
@@ -146,6 +161,30 @@ 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
@@ -188,7 +227,7 @@ function todayStr() {
function openCreate() {
editId.value = null
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: 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
@@ -202,7 +241,9 @@ function openEdit(e) {
category: e.category || '',
amount: e.amount,
memo: e.memo || '',
walletKind: walletKindOf(e.walletId),
walletId: e.walletId || '',
toWalletKind: walletKindOf(e.toWalletId),
toWalletId: e.toWalletId || '',
})
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
@@ -378,40 +419,39 @@ onMounted(async () => {
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<table v-else-if="entries.length" class="entry-table">
<thead>
<tr>
<th class="col-date">날짜</th>
<th class="col-cat">분류</th>
<th>메모</th>
<th class="col-amount">금액</th>
<th class="col-act"></th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.id">
<td class="col-date">{{ formatDate(e.entryDate) }}</td>
<td class="col-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>
</td>
<td class="memo">
<span v-if="e.type === 'TRANSFER'" class="row-wallet">{{ e.walletName }} {{ e.toWalletName }}</span>
<span v-else-if="e.walletName" class="row-wallet">{{ e.walletName }}</span>
{{ e.memo }}
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
</td>
<td class="col-amount" :class="amountClass(e.type)">
{{ amountSign(e.type) }}{{ won(e.amount) }}
</td>
<td class="col-act">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</td>
</tr>
</tbody>
</table>
<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>
<!-- 추가/수정 모달 -->
@@ -432,22 +472,37 @@ onMounted(async () => {
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
</select>
</label>
<label>{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
<select v-model="form.walletId" :disabled="submitting">
<!-- 계좌 종류 먼저 선택 해당 종류만 목록에 -->
<label>계좌 종류
<select v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange">
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
<option v-for="k in WALLET_KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
</select>
</label>
<label v-if="form.type === 'TRANSFER'">입금 계좌
<select v-model="form.toWalletId" :disabled="submitting">
<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 wallets" :key="w.id" :value="w.id">
<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">
@@ -624,43 +679,90 @@ button.primary {
.card.expense .value {
color: #c0392b;
}
.entry-table {
width: 100%;
border-collapse: collapse;
/* 일별 그룹 목록 */
.day-group {
margin-bottom: 1rem;
}
.entry-table th,
.entry-table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
font-size: 0.92rem;
.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;
}
.col-date {
width: 60px;
.day-date {
font-size: 0.9rem;
font-weight: 700;
}
.col-cat {
width: 120px;
white-space: nowrap;
}
.col-amount {
width: 120px;
text-align: right;
.day-sums {
display: flex;
gap: 0.7rem;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.col-amount.income {
.day-sums .income {
color: #2e7d32;
}
.col-amount.expense {
.day-sums .expense {
color: #c0392b;
}
.col-act {
width: 110px;
.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;
}
.col-act button {
padding: 0.2rem 0.5rem;
.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;
@@ -678,14 +780,6 @@ button.primary {
.type-dot.transfer {
background: #888;
}
.col-amount.transfer {
color: var(--color-text);
opacity: 0.8;
}
.memo {
color: var(--color-text);
opacity: 0.85;
}
.msg {
margin: 1rem 0;
}
@@ -826,7 +920,7 @@ button.primary {
opacity: 0;
}
/* ===== 모바일: 내역 표를 2줄(분류·금액 / 날짜·메모·액션)로 ===== */
/* ===== 모바일 ===== */
@media (max-width: 768px) {
.account {
max-width: 100%;
@@ -840,56 +934,8 @@ button.primary {
.card .value {
font-size: 1rem;
}
.entry-table thead {
display: none;
}
.entry-table,
.entry-table tbody,
.entry-table tr,
.entry-table td {
display: block;
}
.entry-table tr {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.5rem;
row-gap: 0.15rem;
padding: 0.6rem 0.1rem;
}
.entry-table td {
border: 0;
padding: 0;
width: auto;
}
.entry-table .col-cat {
order: 0;
flex: 1;
white-space: normal;
font-size: 0.92rem;
}
.entry-table .col-amount {
order: 1;
width: auto;
font-size: 0.92rem;
}
.entry-table .col-date {
order: 2;
width: auto;
font-size: 0.76rem;
opacity: 0.65;
}
.entry-table .memo {
order: 3;
flex: 1;
font-size: 0.8rem;
}
.entry-table .col-act {
order: 4;
width: auto;
margin-left: auto;
display: flex;
gap: 0.25rem;
.ei-wallet {
max-width: 38%;
}
.filter-bar .f-keyword {
flex-basis: 100%;