- 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user