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
+93 -257
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import { appLock } from '@/composables/appLock'
import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { cardNotif } from '@/native/cardNotif'
import { CARD_NOTIF_ENABLED } from '@/config/features'
@@ -290,12 +291,19 @@ async function loadWallets() {
// 계좌 종류(콤보) → 해당 종류만 목록에
const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
{ value: 'LOAN', label: '대출' },
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
{ value: 'LOAN', label: '대출' },
{ value: 'MINUS', label: '마이너스' },
{ value: 'INVEST', label: '증권' },
]
const TYPES = [
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
{ value: 'REPAYMENT', label: '상환/납부', cls: 'chip-repay' },
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
@@ -327,111 +335,12 @@ async function loadCategories() {
categories.value = []
}
}
// ===== 분류: 대분류 아코디언 → 소분류 선택 =====
const categoryMajor = ref('') // 펼쳐진 대분류 이름
// 현재 구분의 대분류 객체 목록
const majorOptionsData = computed(() =>
categories.value.filter((c) => c.type === form.type && c.parentId == null)
)
// 대분류를 4개씩 행으로 묶음
const majorRows = computed(() => {
const items = majorOptionsData.value
const rows = []
for (let i = 0; i < items.length; i += 4) rows.push(items.slice(i, i + 4))
return rows
})
// 현재 펼쳐진 대분류 객체
const selectedMajorObj = computed(() =>
majorOptionsData.value.find((m) => m.name === categoryMajor.value) ?? null
)
// 특정 대분류의 소분류 목록
function subsByMajor(majorId) {
return categories.value.filter((c) => c.parentId === majorId)
}
// 대분류 클릭: 소분류 있으면 펼침 토글, 없으면 바로 선택
function onMajorClick(major) {
if (categoryMajor.value === major.name) {
categoryMajor.value = ''
form.category = ''
} else {
categoryMajor.value = major.name
if (!subsByMajor(major.id).length) form.category = major.name
else form.category = ''
}
}
// form.category 로부터 대분류 추론 (편집 진입 시)
function syncCategoryMajor() {
const cur = form.category
if (!cur) {
categoryMajor.value = ''
return
}
const c = categories.value.find((x) => x.type === form.type && x.name === cur)
if (c && c.parentId != null) {
const parent = categories.value.find((x) => x.id === c.parentId)
categoryMajor.value = parent ? parent.name : cur
} else {
categoryMajor.value = cur
}
}
// 필터용 전체 분류명 (중복 제거)
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
// 모달 내 분류 인라인 추가
const addingCategory = ref(false)
const addingAsMajor = ref(true) // true=대분류 추가, false=소분류 추가
const newCategoryName = ref('')
const catSubmitting = ref(false)
function startAddMajorCategory() {
categoryMajor.value = ''
form.category = ''
newCategoryName.value = ''
addingAsMajor.value = true
addingCategory.value = true
}
function startAddSubCategory() {
newCategoryName.value = ''
addingAsMajor.value = false
addingCategory.value = true
}
function cancelAddCategory() {
addingCategory.value = false
newCategoryName.value = ''
}
async function confirmAddCategory() {
const name = newCategoryName.value.trim()
if (!name) return
catSubmitting.value = true
// 대분류가 선택돼 있으면 그 아래 소분류로 추가, 아니면 대분류로 추가
const parent = categories.value.find(
(c) => c.type === form.type && c.parentId == null && c.name === categoryMajor.value,
)
const parentId = parent ? parent.id : null
try {
await accountApi.createCategory({ type: form.type, name, parentId })
await loadCategories()
form.category = name
if (!parentId) categoryMajor.value = name // 새 대분류면 대분류로 선택
cancelAddCategory()
} catch (e) {
if (e.response?.status === 409) {
// 이미 있는 분류면 그대로 선택
form.category = name
syncCategoryMajor()
cancelAddCategory()
} else {
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
}
} finally {
catSubmitting.value = false
}
}
// 구분(수입/지출/이체) 변경 시 분류 초기화
function onTypeChange() {
form.category = ''
categoryMajor.value = ''
}
// 태그 (태그 관리에 등록된 태그 선택)
@@ -563,8 +472,6 @@ function openCreate() {
editingPending.value = false
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
selectedTagIds.value = []
syncCategoryMajor()
cancelAddCategory()
formError.value = null
formInfo.value = ''
formOpen.value = true
@@ -598,8 +505,6 @@ function openEdit(e) {
const nameToId = {}
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
syncCategoryMajor()
cancelAddCategory()
pasteDetected.value = null
pasteModalOpen.value = false
pasteText.value = ''
@@ -1084,7 +989,7 @@ onMounted(async () => {
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<Transition name="slide-up">
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
<div class="modal entry-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
@@ -1122,24 +1027,26 @@ onMounted(async () => {
</div>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting" @change="onTypeChange">
<option value="EXPENSE">지출</option>
<option value="INCOME">수입</option>
<option value="TRANSFER">이체</option>
<option value="REPAYMENT">상환/납부</option>
</select>
</label>
<!-- 계좌 종류 먼저 선택(라디오) 해당 종류만 셀렉트에 -->
<!-- 구분 배지 -->
<div class="field">
<div class="field-row">
<span class="field-label">계좌 종류</span>
<div class="wallet-radios">
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
<input type="radio" :value="k.value" v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange" />
{{ k.label }}
</label>
</div>
<span class="field-label">구분</span>
<div class="type-chips">
<button v-for="t in TYPES" :key="t.value" type="button"
class="chip" :class="[t.cls, { active: form.type === t.value }]"
:disabled="submitting" @click="form.type = t.value; onTypeChange()">
{{ t.label }}
</button>
</div>
</div>
<!-- 계좌 종류 먼저 선택(배지) 해당 종류만 셀렉트에 -->
<div class="field">
<span class="field-label">계좌 종류</span>
<div class="wallet-chips">
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
class="chip" :class="{ active: form.walletKind === k.value }"
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
{{ k.label }}
</button>
</div>
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option>
@@ -1158,14 +1065,13 @@ onMounted(async () => {
</label>
<template v-if="form.type === 'TRANSFER'">
<div class="field">
<div class="field-row">
<span class="field-label">입금 종류</span>
<div class="wallet-radios">
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
<input type="radio" :value="k.value" v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange" />
{{ k.label }}
</label>
</div>
<span class="field-label">입금 종류</span>
<div class="wallet-chips">
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
class="chip" :class="{ active: form.toWalletKind === k.value }"
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
{{ k.label }}
</button>
</div>
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
<option value="">입금 계좌 선택</option>
@@ -1218,40 +1124,13 @@ onMounted(async () => {
</template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<div v-if="!addingCategory" class="cat-picker">
<!-- 대분류 행별 렌더링: 선택된 대분류가 속한 바로 아래 소분류 삽입 -->
<template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
<div class="acc-major-grid">
<button
v-for="m in row" :key="m.id"
type="button" class="acc-chip major"
:class="{ active: categoryMajor === m.name }"
:disabled="submitting"
@click="onMajorClick(m)"
>{{ m.name }}</button>
</div>
<div v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id) && subsByMajor(selectedMajorObj.id).length" class="acc-sub-grid">
<button
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
type="button" class="acc-chip sub"
:class="{ active: form.category === s.name }"
:disabled="submitting"
@click="form.category = s.name"
>{{ s.name }}</button>
<button type="button" class="acc-chip add-chip sub-add" :disabled="submitting" @click="startAddSubCategory">+ 소분류</button>
</div>
</template>
<button type="button" class="acc-chip add-chip" :disabled="submitting" @click="startAddMajorCategory">+ 대분류</button>
</div>
<div v-else class="cat-input">
<input
v-model="newCategoryName" type="text"
:placeholder="addingAsMajor ? (form.type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류') : `${categoryMajor} > 새 소분류`"
: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>
<CategoryPicker
v-model="form.category"
:type="form.type"
:categories="categories"
:disabled="submitting"
@category-added="loadCategories"
/>
</label>
<label v-if="!isRepayment" class="amount-label">
금액
@@ -1795,6 +1674,27 @@ button.primary {
margin-left: auto;
margin-right: auto;
}
/* 전체화면 내역 모달 슬라이드업 */
.slide-up-enter-active {
transition: background 0.25s ease;
}
.slide-up-leave-active {
transition: background 0.2s ease;
}
.slide-up-enter-from.entry-backdrop,
.slide-up-leave-to.entry-backdrop {
background: rgba(0, 0, 0, 0);
}
.slide-up-enter-active .modal.entry-modal {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-up-leave-active .modal.entry-modal {
transition: transform 0.22s cubic-bezier(0.4, 0, 1, 1);
}
.slide-up-enter-from .modal.entry-modal,
.slide-up-leave-to .modal.entry-modal {
transform: translateY(100%);
}
.modal {
position: relative;
width: 100%;
@@ -1943,112 +1843,48 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
/* 계좌 종류 라디오 (라벨+라디오 한 줄, 선택 종류는 셀렉트로 아래) */
/* 배지형 선택 (구분 · 계좌 종류) */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-label {
font-size: 0.82rem;
flex-shrink: 0;
opacity: 0.7;
}
.wallet-radios {
display: flex;
flex: 1;
min-width: 0;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.wallet-radios .radio {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.15rem;
font-size: 0.82rem;
cursor: pointer;
}
.wallet-radios .radio input {
padding: 0;
width: auto;
margin: 0;
}
.cat-input {
display: flex;
gap: 0.4rem;
}
.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);
}
/* 분류 칩 그리드 */
.cat-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.acc-major-grid,
.acc-sub-grid {
.type-chips,
.wallet-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.acc-sub-grid {
padding: 0.4rem;
border-radius: 6px;
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
}
.acc-chip {
flex: 1 1 calc(25% - 0.35rem);
min-width: 0;
padding: 0.38rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.82rem;
color: var(--color-text);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.acc-chip.major.active {
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 구분 배지 active 색상 */
.chip-expense.active { border-color: #c0392b; background: #c0392b; color: #fff; }
.chip-income.active { border-color: #2e7d32; background: #2e7d32; color: #fff; }
.chip-transfer.active { border-color: #1565c0; background: #1565c0; color: #fff; }
.chip-repay.active { border-color: #6a1fa2; background: #6a1fa2; color: #fff; }
/* 계좌 종류 배지 active 색상 */
.wallet-chips .chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.acc-chip.sub {
background: var(--color-background);
}
.acc-chip.sub.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.acc-chip.add-chip {
opacity: 0.55;
border-style: dashed;
}
.acc-chip.add-chip.sub-add {
flex: 0 0 auto;
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.loan-breakdown {
display: flex;