feat(ui): 입력 모달 전체화면 슬라이드업 + 셀렉트→뱃지 전면 전환
Deploy / deploy (push) Failing after 12m1s

- AppModal.vue: 전체화면 슬라이드업 공통 모달 (Teleport+Transition)
- CategoryPicker.vue: 대/소분류 아코디언 칩 공통 컴포넌트
  admin 모드(일반: 행 단위+수정/삭제, 순서변경: flat+SortableJS)
- AccountView: 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
- RecurringView: 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
- AccountWalletView: 카드종류·상환방식 셀렉트→뱃지
- BudgetView: 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- CategoryView: 리스트 UI → CategoryPicker 칩 그리드 관리 모드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-04 01:24:15 +09:00
parent d364b52c73
commit ea9fbc359b
10 changed files with 1545 additions and 1367 deletions
+117 -117
View File
@@ -4,6 +4,7 @@ import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
const dialog = useDialog()
@@ -12,8 +13,19 @@ const TABS = [
{ key: 'CASH', label: '현금' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' },
]
const CARD_TYPES = [
{ value: 'CREDIT', label: '신용카드' },
{ value: 'CHECK', label: '체크카드' },
]
const LOAN_METHODS = [
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
{ value: 'BULLET', label: '만기일시' },
{ value: '', label: '선택안함' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
@@ -85,7 +97,7 @@ function shiftMonth(w, delta) {
entryMonth[w.id] = ms[j]
}
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
// 현재 탭(activeType)의 계좌 — 드래그 정렬 대상 (백엔드가 sort_order 순 정렬)
const rows = ref([])
@@ -160,7 +172,7 @@ function entryDesc(e, w) {
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
const inLabel = w.type === 'CARD' || w.type === 'LOAN' || w.type === 'MINUS' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
@@ -199,10 +211,14 @@ function toggleReveal(id) {
revealedAccts.value = s
}
function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
if (t === 'CARD') return '초기 미결제 금액'
if (t === 'INVEST') return '투자금(투입원금)'
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
return '기록 시작 시 잔액'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
@@ -378,7 +394,7 @@ onBeforeUnmount(() => sortable?.destroy())
</div>
<span class="sub">
{{ w.issuer }}
<template v-if="w.type === 'BANK' && w.accountNumber">
<template v-if="(w.type === 'BANK' || w.type === 'MINUS') && w.accountNumber">
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
<button
type="button" class="acct-eye"
@@ -453,78 +469,75 @@ onBeforeUnmount(() => sortable?.destroy())
<div v-if="!loading && !rows.length" class="empty-state">
<p class="empty-emoji">🏦</p>
<p class="empty-title">아직 등록된 계좌가 없어요</p>
<p class="empty-desc">은행·카드·현금·대출·투자를 추가해보세요.</p>
<p class="empty-desc">은행·카드·현금·대출·마이너스통장·투자를 추가해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 계좌 추가</button>
</div>
<!-- 추가/수정 모달 -->
<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 v-if="form.type !== 'CASH'">{{ 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>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
placeholder="예: 5.25" :disabled="submitting" />
</label>
<label>상환방식
<select v-model="form.loanMethod" :disabled="submitting">
<option value="">(선택 )</option>
<option value="EQUAL_PAYMENT">원리금균등상환</option>
<option value="EQUAL_PRINCIPAL">원금균등상환</option>
<option value="BULLET">만기일시상환</option>
</select>
</label>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
</template>
<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>
<AppModal v-model="formOpen" :title="`${TABS.find((t) => t.key === form.type)?.label} ${editId ? '수정' : '추가'}`">
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="form.type === 'CARD'" class="field">
<span class="field-label">카드 종류</span>
<div class="chip-group">
<button
v-for="c in CARD_TYPES" :key="c.value"
type="button" class="chip" :class="{ active: form.cardType === c.value }"
:disabled="submitting" @click="form.cardType = c.value"
>{{ c.label }}</button>
</div>
</div>
</Transition>
</Teleport>
<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>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
placeholder="예: 5.25" :disabled="submitting" />
</label>
<div class="field">
<span class="field-label">상환방식</span>
<div class="chip-group">
<button
v-for="m in LOAN_METHODS" :key="m.value"
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
:disabled="submitting" @click="form.loanMethod = m.value"
>{{ m.label }}</button>
</div>
</div>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
</template>
<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>
</AppModal>
</section>
</template>
@@ -893,43 +906,6 @@ button.primary {
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;
@@ -949,21 +925,45 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
/* 배지형 선택 */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.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%;