Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37b4a9f4cf | |||
| afbec5e3c3 | |||
| ee7a1fb471 |
@@ -110,6 +110,15 @@
|
|||||||
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
|
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
|
||||||
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
|
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회 캐시·집계수치만 전송.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔨 빌드 상태
|
## 🔨 빌드 상태
|
||||||
|
|||||||
@@ -276,25 +276,27 @@ async function loadCategories() {
|
|||||||
categories.value = []
|
categories.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
|
// ===== 분류: 대분류 아코디언 → 소분류 선택 =====
|
||||||
// ===== 분류: 대분류 → 소분류 2단 선택 (긴 드롭다운 스크롤 방지) =====
|
const categoryMajor = ref('') // 펼쳐진 대분류 이름
|
||||||
const categoryMajor = ref('') // 선택된 대분류 이름
|
// 현재 구분의 대분류 객체 목록
|
||||||
// 현재 구분의 대분류(부모 없음) 이름 + 편집 중 현재 대분류 보존
|
const majorOptionsData = computed(() =>
|
||||||
const majorOptions = computed(() => {
|
categories.value.filter((c) => c.type === form.type && c.parentId == null)
|
||||||
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)
|
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 로부터 대분류 추론 (편집 진입 시)
|
// form.category 로부터 대분류 추론 (편집 진입 시)
|
||||||
function syncCategoryMajor() {
|
function syncCategoryMajor() {
|
||||||
const cur = form.category
|
const cur = form.category
|
||||||
@@ -310,10 +312,7 @@ function syncCategoryMajor() {
|
|||||||
categoryMajor.value = cur
|
categoryMajor.value = cur
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 대분류 변경: 소분류 있으면 소분류 선택 대기 / 없으면 대분류가 곧 분류
|
|
||||||
function onMajorChange() {
|
|
||||||
form.category = subOptions.value.length ? '' : categoryMajor.value
|
|
||||||
}
|
|
||||||
// 필터용 전체 분류명 (중복 제거)
|
// 필터용 전체 분류명 (중복 제거)
|
||||||
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
||||||
|
|
||||||
@@ -1121,16 +1120,28 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
||||||
<div v-if="!addingCategory" class="cat-input cascade">
|
<div v-if="!addingCategory" class="cat-accordion">
|
||||||
<select v-model="categoryMajor" :disabled="submitting" @change="onMajorChange" title="대분류">
|
<div v-for="m in majorOptionsData" :key="m.id" class="acc-major">
|
||||||
<option value="">대분류 선택</option>
|
<button
|
||||||
<option v-for="m in majorOptions" :key="m" :value="m">{{ m }}</option>
|
type="button" class="acc-major-btn"
|
||||||
</select>
|
:class="{ active: form.category === m.name && !subsByMajor(m.id).length, expanded: categoryMajor === m.name && subsByMajor(m.id).length }"
|
||||||
<select v-if="subOptions.length" v-model="form.category" :disabled="submitting" title="소분류">
|
:disabled="submitting"
|
||||||
<option value="">소분류 선택</option>
|
@click="onMajorClick(m)"
|
||||||
<option v-for="s in subOptions" :key="s" :value="s">{{ s }}</option>
|
>
|
||||||
</select>
|
<span class="acc-arrow">{{ subsByMajor(m.id).length ? (categoryMajor === m.name ? '▼' : '▶') : '' }}</span>
|
||||||
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
|
{{ m.name }}
|
||||||
|
</button>
|
||||||
|
<div v-if="categoryMajor === m.name && subsByMajor(m.id).length" class="acc-subs">
|
||||||
|
<button
|
||||||
|
v-for="s in subsByMajor(m.id)" :key="s.id"
|
||||||
|
type="button" class="acc-sub-btn"
|
||||||
|
:class="{ active: form.category === s.name }"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="form.category = s.name"
|
||||||
|
>{{ s.name }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cat-add-btn acc-add" :disabled="submitting" @click="startAddCategory">+ 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="cat-input">
|
<div v-else class="cat-input">
|
||||||
<input
|
<input
|
||||||
@@ -1871,13 +1882,6 @@ button.primary {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.cat-input.cascade {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.cat-input.cascade select {
|
|
||||||
flex: 1 1 40%;
|
|
||||||
}
|
|
||||||
.cat-input select,
|
|
||||||
.cat-input input {
|
.cat-input input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1892,6 +1896,79 @@ button.primary {
|
|||||||
border-color: hsla(160, 100%, 37%, 1);
|
border-color: hsla(160, 100%, 37%, 1);
|
||||||
color: hsla(160, 100%, 37%, 1);
|
color: hsla(160, 100%, 37%, 1);
|
||||||
}
|
}
|
||||||
|
/* 분류 아코디언 */
|
||||||
|
.cat-accordion {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.acc-major-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.acc-major-btn:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.acc-major-btn.active,
|
||||||
|
.acc-major-btn.expanded {
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.acc-major-btn.active {
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
}
|
||||||
|
.acc-arrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
flex: none;
|
||||||
|
width: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.acc-subs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.acc-sub-btn {
|
||||||
|
padding: 0.38rem 0.7rem 0.38rem 2rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.acc-sub-btn:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.acc-sub-btn.active {
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.acc-add {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
.tag-field {
|
.tag-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -66,17 +66,6 @@ async function reload() {
|
|||||||
const majorListEl = ref(null)
|
const majorListEl = ref(null)
|
||||||
const reorderMode = ref(false)
|
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 = []
|
let sortables = []
|
||||||
function destroySortables() {
|
function destroySortables() {
|
||||||
sortables.forEach((s) => s.destroy())
|
sortables.forEach((s) => s.destroy())
|
||||||
@@ -140,16 +129,9 @@ async function addCategory() {
|
|||||||
if (!name) return
|
if (!name) return
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const parentId = newParent.value || null
|
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
|
||||||
await accountApi.createCategory({ type: activeType.value, name, parentId })
|
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
await reload()
|
await reload()
|
||||||
// 소분류를 추가했으면 그 대분류를 펼쳐 바로 보이게
|
|
||||||
if (parentId) {
|
|
||||||
const s = new Set(expanded.value)
|
|
||||||
s.add(Number(parentId))
|
|
||||||
expanded.value = s
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e, '추가에 실패했습니다.')
|
showError(e, '추가에 실패했습니다.')
|
||||||
}
|
}
|
||||||
@@ -245,18 +227,12 @@ onBeforeUnmount(destroySortables)
|
|||||||
<div class="cat-row major-row">
|
<div class="cat-row major-row">
|
||||||
<div class="cat-main">
|
<div class="cat-main">
|
||||||
<span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)">≡</span>
|
<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)" />
|
<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="check" title="저장" size="sm" @click="saveCategory(g.major)" />
|
||||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
|
||||||
<div class="cat-main">
|
<div class="cat-main">
|
||||||
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
||||||
@@ -468,37 +444,7 @@ button.danger {
|
|||||||
.sub-mark {
|
.sub-mark {
|
||||||
opacity: 0.45;
|
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 {
|
.hint.sm {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user