Compare commits

...

8 Commits

Author SHA1 Message Date
ByungCheol 1b53af525e feat(account): 대출 실행금액 필드 추가 + 상환방식별 자동계산 분기
CI / build (push) Failing after 14m30s
- loan_amount(대출 실행 금액) 컬럼 추가(DB/도메인/DTO/mapper)
- 계좌 폼: 대출 실행 금액 입력 / 기록 시작 시 잔액(기존 openingBalance) 레이블 분리
- 계좌 카드: 실행금액·금리·상환방식 표시
- 상환 자동계산 3방식 분기:
  - 원리금균등: 납입금액 입력 → 이자(잔액×월이율) / 원금(납입-이자) 분리
  - 원금균등: 실행금액÷기간=월원금, 잔액×월이율=이자 자동계산
  - 만기일시: 잔액×월이율=이자만 자동계산, 원금 0
- 자동계산 후 이자·원금 수동 조정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:31:50 +09:00
ByungCheol f8e6c6b0ee feat(account): 대출 계좌 금리/상환방식 저장 + 상환 시 납입금액 자동분리
CI / build (push) Failing after 14m10s
- wallet 테이블에 loan_rate/loan_method/loan_months/loan_start 컬럼 추가(ALTER TABLE)
- 대출 계좌 폼에 연이자율(%), 상환방식(원리금균등/원금균등/만기일시), 기간, 시작일 입력 필드 추가
- 계좌 목록에 금리·상환방식 표시
- 상환 폼: 금리 설정된 대출 계좌 선택 시 납입금액 입력 → 이자/원금 자동계산
  - 원리금균등/원금균등: 잔액 × 연이율/12 = 이자, 납입액-이자 = 원금
  - 만기일시상환: 납입액 전체 이자로 처리
  - 자동계산 후 수동 조정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:02:10 +09:00
ByungCheol e1c077dccd fix(account): 소분류 표시 조건 — template 인라인으로 reactivity 보장
CI / build (push) Failing after 14m42s
rowContainsSelected 함수 대신 template에서 row.some(m => m.id === selectedMajorObj.id) 직접 평가.
함수 호출 시 의존성 추적 불안정 문제 해소.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:41:43 +09:00
ByungCheol 1e9ff0408c feat(account): 내역추가 분류 — 행 삽입형 아코디언 + 대/소분류 추가 버튼 분리
CI / build (push) Failing after 12m46s
- 대분류를 4개씩 행으로 렌더링(majorRows computed)
- 선택된 대분류가 속한 행 바로 아래 소분류 칩 삽입(rowContainsSelected)
- + 소분류 버튼: 소분류 행 내부, 선택된 대분류 하위로 추가
- + 대분류 버튼: 맨 아래, 선택 초기화 후 대분류로 추가
- 입력 placeholder에 추가 맥락 표시(대분류명 > 새 소분류)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:32:59 +09:00
ByungCheol b2f3b156b2 fix: 분류 소분류 영역 배경색 구분 — 회색 박스로 대분류와 시각적 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:19:27 +09:00
ByungCheol 28ebe06767 fix: 분류 선택 UI — 세로 아코디언→가로 칩 그리드로 변경
- 대분류: flex-wrap 그리드(한 줄 4개)로 나열, 분류 수 증가해도 가로로 쌓임
- 대분류 선택 시 소분류가 구분선 아래 동일 그리드로 펼침
- 선택된 항목 초록 테두리·배경 강조

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:13:58 +09:00
ByungCheol afbec5e3c3 fix: 분류 아코디언 화면 교정 — 분류관리 롤백 · 내역추가 적용
- CategoryView(분류 관리): 잘못 적용된 대분류 아코디언 제거, 소분류 항상 표시로 복원
- AccountView(내역 추가/수정): 2단 드롭다운 → 대분류 아코디언으로 교체
  · 대분류 클릭 → 소분류 펼침(없으면 바로 선택)
  · 소분류 클릭 → 즉시 선택 / 선택 항목 초록 하이라이트
  · 수정 진입 시 해당 대분류 자동 펼침 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 23:23:08 +09:00
ByungCheol ee7a1fb471 docs(brand): 백로그에 AI 기능(입력 보조·재무 코멘트) 추가 — 출시 전 적용 희망 2026-06-30 23:02:41 +09:00
4 changed files with 288 additions and 102 deletions
+9
View File
@@ -110,6 +110,15 @@
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
- **AI 입력 보조** (편의성, 1순위)
-**자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
-**영수증 사진 → AI 구조화** — 멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
-**자동 분류 추천** — 가맹점/메모→분류 제안
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
---
## 🔨 빌드 상태
+219 -44
View File
@@ -1,4 +1,4 @@
<script setup>
1<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
@@ -127,6 +127,56 @@ const repayTargetIsCard = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return !!w && w.type === 'CARD'
})
// 금리가 설정된 대출 계좌를 선택한 경우 자동계산 활성
const repayTargetLoanWallet = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return w?.type === 'LOAN' && w.loanRate ? w : null
})
// 납입금액 입력용 (원리금균등만 사용)
const loanPaymentAmount = ref(null)
function monthlyInterestOf(loan) {
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
}
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
const loanAutoResult = computed(() => {
const loan = repayTargetLoanWallet.value
if (!loan) return null
const interest = monthlyInterestOf(loan)
if (loan.loanMethod === 'BULLET') {
return { interest, principal: 0, total: interest, needsInput: false }
}
if (loan.loanMethod === 'EQUAL_PRINCIPAL' && loan.loanAmount && loan.loanMonths) {
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
return { interest, principal, total: principal + interest, needsInput: false }
}
return { interest: null, principal: null, total: null, needsInput: true }
})
watch(loanAutoResult, (r) => {
if (r && !r.needsInput) {
form.interest = r.interest
form.principal = r.principal
loanPaymentAmount.value = r.total
}
})
function calcLoanRepayment(payment) {
const loan = repayTargetLoanWallet.value
if (!loan || !payment || payment <= 0) return
const interest = monthlyInterestOf(loan)
form.interest = Math.min(interest, payment)
form.principal = Math.max(0, payment - form.interest)
}
watch(loanPaymentAmount, (val) => {
if (loanAutoResult.value?.needsInput && val) calcLoanRepayment(Number(val))
})
watch(() => form.toWalletId, () => {
loanPaymentAmount.value = null
form.principal = null
form.interest = null
})
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => {
@@ -276,25 +326,39 @@ async function loadCategories() {
categories.value = []
}
}
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
// ===== 분류: 대분류 → 소분류 2단 선택 (긴 드롭다운 스크롤 방지) =====
const categoryMajor = ref('') // 선택된 대분류 이름
// 현재 구분의 대분류(부모 없음) 이름 + 편집 중 현재 대분류 보존
const majorOptions = computed(() => {
const names = categories.value
.filter((c) => c.type === form.type && c.parentId == null)
.map((c) => c.name)
if (categoryMajor.value && !names.includes(categoryMajor.value)) names.unshift(categoryMajor.value)
return names
})
// 선택된 대분류의 소분류 이름 목록 (없으면 대분류 자체가 분류)
const subOptions = computed(() => {
const major = categories.value.find(
(c) => c.type === form.type && c.parentId == null && c.name === categoryMajor.value,
)
if (!major) return []
return categories.value.filter((c) => c.parentId === major.id).map((c) => c.name)
// ===== 분류: 대분류 아코디언 → 소분류 선택 =====
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
@@ -310,19 +374,25 @@ function syncCategoryMajor() {
categoryMajor.value = cur
}
}
// 대분류 변경: 소분류 있으면 소분류 선택 대기 / 없으면 대분류가 곧 분류
function onMajorChange() {
form.category = subOptions.value.length ? '' : categoryMajor.value
}
// 필터용 전체 분류명 (중복 제거)
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 startAddCategory() {
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() {
@@ -1115,27 +1185,67 @@ onMounted(async () => {
</option>
</select>
</label>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<template v-if="repayTargetLoanWallet">
<!-- 원리금균등: 납입금액 직접 입력 -->
<template v-if="loanAutoResult?.needsInput">
<label>납입금액(원리금균등)
<input v-model.number="loanPaymentAmount" type="number" min="0"
placeholder="이번 달 납입할 총금액" :disabled="submitting" />
</label>
</template>
<!-- 원금균등·만기일시: 자동계산 결과 표시 -->
<template v-else>
<div class="loan-breakdown">
<span class="bd-label">{{ repayTargetLoanWallet.loanMethod === 'BULLET' ? '만기일시' : '원금균등' }} 자동계산</span>
<span>납입 <b>{{ (loanAutoResult?.total || 0).toLocaleString('ko-KR') }}</b></span>
</div>
</template>
<!-- 계산 결과 표시 + 수동 조정 -->
<div v-if="(form.interest ?? null) !== null || (form.principal ?? null) !== null" class="loan-breakdown">
<span>이자 <b>{{ (form.interest || 0).toLocaleString('ko-KR') }}</b></span>
<span>원금 <b>{{ (form.principal || 0).toLocaleString('ko-KR') }}</b></span>
</div>
<label>이자(조정)<input v-model.number="form.interest" type="number" min="0" :disabled="submitting" /></label>
<label>원금(조정)<input v-model.number="form.principal" type="number" min="0" :disabled="submitting" /></label>
</template>
<template v-else>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
</template>
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
</template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<div v-if="!addingCategory" class="cat-input cascade">
<select v-model="categoryMajor" :disabled="submitting" @change="onMajorChange" title="대분류">
<option value="">대분류 선택</option>
<option v-for="m in majorOptions" :key="m" :value="m">{{ m }}</option>
</select>
<select v-if="subOptions.length" v-model="form.category" :disabled="submitting" title="소분류">
<option value="">소분류 선택</option>
<option v-for="s in subOptions" :key="s" :value="s">{{ s }}</option>
</select>
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
<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="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
: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>
@@ -1871,13 +1981,6 @@ button.primary {
display: flex;
gap: 0.4rem;
}
.cat-input.cascade {
flex-wrap: wrap;
}
.cat-input.cascade select {
flex: 1 1 40%;
}
.cat-input select,
.cat-input input {
flex: 1;
min-width: 0;
@@ -1892,6 +1995,78 @@ button.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 {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.acc-sub-grid {
padding: 0.4rem;
border-radius: 6px;
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;
}
.acc-chip.major.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;
}
.loan-breakdown {
display: flex;
gap: 1rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
background: var(--color-background-soft);
font-size: 0.85rem;
color: var(--color-text);
}
.loan-breakdown b {
color: hsla(160, 100%, 37%, 1);
}
.loan-breakdown .bd-label {
width: 100%;
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.1rem;
}
.tag-field {
display: flex;
flex-direction: column;
+57 -1
View File
@@ -33,6 +33,12 @@ const form = reactive({
openingBalance: 0,
openingDate: '',
currentValue: null,
// 대출(LOAN) 전용
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
const submitting = ref(false)
const formError = ref(null)
@@ -196,7 +202,7 @@ function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)'
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
@@ -229,6 +235,11 @@ function openCreate() {
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
formError.value = null
formOpen.value = true
@@ -245,6 +256,11 @@ function openEdit(w) {
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
})
formError.value = null
formOpen.value = true
@@ -258,6 +274,7 @@ async function submit() {
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
@@ -271,6 +288,11 @@ async function submit() {
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
@@ -297,6 +319,9 @@ async function remove(w) {
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
onMounted(async () => {
await load()
@@ -362,6 +387,10 @@ onBeforeUnmount(() => sortable?.destroy())
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span>
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
</template>
</span>
</div>
<div class="balance-wrap">
@@ -458,6 +487,33 @@ onBeforeUnmount(() => sortable?.destroy())
</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">
+3 -57
View File
@@ -66,17 +66,6 @@ async function reload() {
const majorListEl = ref(null)
const reorderMode = ref(false)
// ===== () =====
const expanded = ref(new Set())
function toggleExpand(id) {
const s = new Set(expanded.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expanded.value = s
}
function isExpanded(id) {
return expanded.value.has(id)
}
let sortables = []
function destroySortables() {
sortables.forEach((s) => s.destroy())
@@ -140,16 +129,9 @@ async function addCategory() {
if (!name) return
error.value = null
try {
const parentId = newParent.value || null
await accountApi.createCategory({ type: activeType.value, name, parentId })
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = ''
await reload()
//
if (parentId) {
const s = new Set(expanded.value)
s.add(Number(parentId))
expanded.value = s
}
} catch (e) {
showError(e, '추가에 실패했습니다.')
}
@@ -245,18 +227,12 @@ onBeforeUnmount(destroySortables)
<div class="cat-row major-row">
<div class="cat-main">
<span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<button
type="button" class="expand-btn" :class="{ open: isExpanded(g.major.id), empty: !g.children.length }"
:title="g.children.length ? (isExpanded(g.major.id) ? '소분류 접기' : '소분류 펼치기') : '소분류 없음'"
:disabled="!g.children.length" @click="toggleExpand(g.major.id)"
></button>
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
<span v-if="g.children.length && !isExpanded(g.major.id)" class="sub-count">{{ g.children.length }}</span>
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
</div>
</div>
<ul v-show="isExpanded(g.major.id) || reorderMode" class="sub-list" :data-parent="g.major.id">
<ul class="sub-list" :data-parent="g.major.id">
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
<div class="cat-main">
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경"></span>
@@ -468,37 +444,7 @@ button.danger {
.sub-mark {
opacity: 0.45;
}
/* 대분류 펼침 삼각형 */
.expand-btn {
flex: none;
width: 1.4rem;
height: 1.4rem;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 0.7rem;
cursor: pointer;
transition: transform 0.15s ease;
opacity: 0.7;
}
.expand-btn.open {
transform: rotate(90deg);
}
.expand-btn.empty {
opacity: 0.15;
cursor: default;
}
.sub-count {
flex: none;
min-width: 1.2rem;
text-align: center;
font-size: 0.72rem;
padding: 0.05rem 0.3rem;
border-radius: 999px;
background: var(--color-border);
color: var(--color-text);
opacity: 0.75;
}
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;