Compare commits
28 Commits
48c3664136
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b53af525e | |||
| f8e6c6b0ee | |||
| e1c077dccd | |||
| 1e9ff0408c | |||
| b2f3b156b2 | |||
| 28ebe06767 | |||
| afbec5e3c3 | |||
| ee7a1fb471 | |||
| 1a8aa8e13c | |||
| e87bc244fe | |||
| f8369f5108 | |||
| 80d7b8d552 | |||
| 764436ebc9 | |||
| 970e9e14ad | |||
| f996075da7 | |||
| 5795f328a1 | |||
| 4f08b47fc3 | |||
| dab6dc3411 | |||
| caa591a16d | |||
| 971e08282f | |||
| f42805f96e | |||
| a0aba1d578 | |||
| ce7488076a | |||
| 2eaf8695b0 | |||
| f23b4963a9 | |||
| b50a81028f | |||
| 6972cb5a63 | |||
| bd5f1afd47 |
@@ -15,8 +15,8 @@ android {
|
|||||||
applicationId "kr.sblog.slimbudget"
|
applicationId "kr.sblog.slimbudget"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 3
|
||||||
versionName "1.0"
|
versionName "1.0.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+54
-22
@@ -52,23 +52,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚧 현재 상태 (블로커)
|
## 🚧 현재 상태 (2026-06-30)
|
||||||
- **개인 개발자 계정 신원확인 대기 중** — 앱 생성 버튼 잠김
|
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
|
||||||
- 주소 증빙서류 제출 이슈: 한국 서류 이름 마스킹/주소 미표기 → 도시가스/카드/은행창구 등 무마스킹 서류 확보 중
|
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
|
||||||
- 승인 소요: 보통 2~3 영업일, 길면 1~2주
|
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
|
||||||
|
- ✅ **비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
|
||||||
|
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
|
||||||
|
- ⏳ **테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
|
||||||
|
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 남은 로드맵
|
## 📋 남은 로드맵 (프로덕션까지)
|
||||||
|
|
||||||
1. **신원확인 승인** (대기) → "앱 만들기" 활성화
|
1. ⏳ **테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
|
||||||
2. **앱 만들기** — 이름 "돈돼지 가계부", 한국어, 무료
|
2. ⏳ **14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
|
||||||
3. **익명 셋업** (아래 체크리스트)
|
3. **v3 AAB 업로드** — 이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
|
||||||
4. **필수 설정** — 데이터안전·콘텐츠등급·앱액세스(둘러보기 안내)·광고 없음·금융기능 선언·방침 URL (자료: play-store-listing.md)
|
4. 승인 → **프로덕션 출시**
|
||||||
5. **스토어 등록정보** — 문구 + 아이콘512·피처·스크린샷7
|
|
||||||
6. **비공개 테스트 트랙** — AAB 업로드 + **테스터 12명 opt-in**
|
|
||||||
7. **14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
|
|
||||||
8. 승인 → **프로덕션 출시**
|
|
||||||
|
|
||||||
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
|
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
|
||||||
- **테스터 12명 이상 · 14일 연속** 폐쇄 테스트 후 프로덕션 신청 가능
|
- **테스터 12명 이상 · 14일 연속** 폐쇄 테스트 후 프로덕션 신청 가능
|
||||||
@@ -85,16 +85,48 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💡 출시 전 다듬기 후보 (대기 시간 활용, 선택)
|
## ✅ 적용 완료 (전부 웹/소스 배포됨)
|
||||||
신규(빈손) 유저 경험 위주 — 저위험·고효율 순:
|
|
||||||
1. **빈 상태 → 행동 유도** (첫 내역/계좌 추가 CTA) ⭐ 임팩트 高/노력 低
|
**v2(versionCode 2, 1.0.1, 06-29)에 포함 — 비공개 테스트 게시·검토통과**
|
||||||
2. **오프라인·네트워크 오류 UX 견고화** (폐쇄 테스트 대비)
|
- 신규 유저 빈 상태 CTA(홈 '시작하기'·내역·계좌) / 오프라인·네트워크 오류 배너
|
||||||
3. 첫 실행 온보딩(시작하기 카드)
|
- 앱 잠금(PIN, 옵트인) + 카메라/갤러리 복귀 오작동 수정
|
||||||
4. Play 인앱 리뷰 요청
|
- 전역 에러 폴백(흰 화면 방지) / 안드로이드 뒤로가기 통합(오버레이→뒤로→종료토스트)
|
||||||
5. (선택) 앱 잠금(PIN/생체) — 매니페스트에 USE_BIOMETRIC 권한 존재, 구현 여부 미확인
|
- 분류 순서변경 보호('순서변경' 토글)
|
||||||
|
|
||||||
|
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
|
||||||
|
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
|
||||||
|
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
|
||||||
|
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
|
||||||
|
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
|
||||||
|
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 남은 백로그 (출시 후 업데이트)
|
||||||
|
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
|
||||||
|
2. **관리자 신고글 목록 페이지**
|
||||||
|
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
|
||||||
|
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
|
||||||
|
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회 캐시·집계수치만 전송.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔨 빌드 상태
|
||||||
|
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
|
||||||
|
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 다음 세션 재개 지점
|
## 다음 세션 재개 지점
|
||||||
**신원확인 승인 여부 확인** → 승인됐으면 "앱 만들기 → 익명 셋업 → 필수설정/등록정보 입력" 진행.
|
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
|
||||||
대기 중이면 **테스터 12명 Gmail 확보** + **출시 전 다듬기(빈 상태/오프라인)** 진행 권장.
|
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
|
||||||
|
|||||||
+127
-3
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||||
@@ -11,6 +11,7 @@ import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
|||||||
import AppDialog from '@/components/ui/AppDialog.vue'
|
import AppDialog from '@/components/ui/AppDialog.vue'
|
||||||
import AppLockScreen from '@/components/AppLockScreen.vue'
|
import AppLockScreen from '@/components/AppLockScreen.vue'
|
||||||
import { appLock } from '@/composables/appLock'
|
import { appLock } from '@/composables/appLock'
|
||||||
|
import { appError, reportFatal } from '@/composables/appError'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { reminders } from '@/native/reminders'
|
import { reminders } from '@/native/reminders'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -74,9 +75,33 @@ function retryReload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 앱 잠금(PIN) =====
|
// ===== 앱 잠금(PIN) =====
|
||||||
// 백그라운드로 가면 잠가서, 복귀 시 잠금 화면이 떠 있도록 한다.
|
// 잠깐 다른 앱 다녀온 정도로는 잠그지 않고, 일정 시간 이상 백그라운드였을 때만 잠근다.
|
||||||
|
// (완전 종료/재시작은 onMounted 에서 잠금) — 카메라/갤러리/공유 등 인앱 동작은 suppress 로 제외.
|
||||||
|
const LOCK_GRACE_MS = 60 * 60 * 1000 // 1시간 미만 이탈은 잠그지 않음(잦은 PIN 입력 방지)
|
||||||
|
let backgroundedAt = 0
|
||||||
function onVisibility() {
|
function onVisibility() {
|
||||||
if (document.hidden) appLock.lock()
|
if (document.hidden) {
|
||||||
|
backgroundedAt = Date.now()
|
||||||
|
} else {
|
||||||
|
const away = backgroundedAt ? Date.now() - backgroundedAt : 0
|
||||||
|
backgroundedAt = 0
|
||||||
|
if (away >= LOCK_GRACE_MS) appLock.lock() // lock() 은 suppress 창이면 건너뜀
|
||||||
|
appLock.clearSuppress() // 복귀 — 일시중지 창 해제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 파일 선택(input[type=file]) 클릭 시 다음 백그라운드 전환을 잠금에서 제외
|
||||||
|
function onFileInputClick(e) {
|
||||||
|
const t = e.target
|
||||||
|
if (t && t.tagName === 'INPUT' && t.type === 'file') appLock.suppressLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 토스트 (뒤로가기 종료 안내 등) =====
|
||||||
|
const toastMsg = ref('')
|
||||||
|
let toastTimer = null
|
||||||
|
function onToast(e) {
|
||||||
|
toastMsg.value = e.detail || ''
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toastTimer = setTimeout(() => { toastMsg.value = '' }, 1800)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -86,9 +111,11 @@ onMounted(() => {
|
|||||||
window.addEventListener('online', onOnline)
|
window.addEventListener('online', onOnline)
|
||||||
window.addEventListener('offline', onOffline)
|
window.addEventListener('offline', onOffline)
|
||||||
window.addEventListener('net:error', onNetError)
|
window.addEventListener('net:error', onNetError)
|
||||||
|
window.addEventListener('app:toast', onToast)
|
||||||
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
||||||
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
document.addEventListener('click', onFileInputClick, true)
|
||||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||||
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
@@ -110,8 +137,17 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('online', onOnline)
|
window.removeEventListener('online', onOnline)
|
||||||
window.removeEventListener('offline', onOffline)
|
window.removeEventListener('offline', onOffline)
|
||||||
window.removeEventListener('net:error', onNetError)
|
window.removeEventListener('net:error', onNetError)
|
||||||
|
window.removeEventListener('app:toast', onToast)
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
document.removeEventListener('click', onFileInputClick, true)
|
||||||
clearTimeout(netTimer)
|
clearTimeout(netTimer)
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
|
||||||
|
onErrorCaptured((err, _instance, info) => {
|
||||||
|
reportFatal({ err, info })
|
||||||
|
return false // 상위로 전파 중단
|
||||||
})
|
})
|
||||||
|
|
||||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||||
@@ -119,6 +155,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 치명적 오류 폴백 (흰 화면 방지) -->
|
||||||
|
<div v-if="appError.hasError" class="fatal-screen">
|
||||||
|
<div class="fatal-inner">
|
||||||
|
<div class="fatal-emoji">😵</div>
|
||||||
|
<h1 class="fatal-title">문제가 발생했어요</h1>
|
||||||
|
<p class="fatal-sub">일시적인 오류일 수 있어요. 새로고침 후 다시 시도해 주세요.</p>
|
||||||
|
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<!-- 웹(브라우저): 공개 법적 고지는 노출, 그 외엔 안내 페이지 -->
|
<!-- 웹(브라우저): 공개 법적 고지는 노출, 그 외엔 안내 페이지 -->
|
||||||
<template v-if="!isApp">
|
<template v-if="!isApp">
|
||||||
<RouterView v-if="isPublicLegal" />
|
<RouterView v-if="isPublicLegal" />
|
||||||
@@ -162,11 +209,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
|
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<SignupModal />
|
<SignupModal />
|
||||||
|
|
||||||
|
<!-- 토스트 (뒤로가기 종료 안내 등) -->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
|
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
|
||||||
<AppDialog />
|
<AppDialog />
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout {
|
.layout {
|
||||||
@@ -246,6 +299,77 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
transform: translateY(-100%);
|
transform: translateY(-100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 토스트 */
|
||||||
|
.app-toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(72px + env(safe-area-inset-bottom, 0));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2600;
|
||||||
|
max-width: 80vw;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 치명적 오류 폴백 화면 */
|
||||||
|
.fatal-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
.fatal-inner {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.fatal-emoji {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
}
|
||||||
|
.fatal-title {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
.fatal-sub {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
.fatal-btn {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
padding: 0.7rem 1.8rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fatal-btn:hover {
|
||||||
|
background: hsla(160, 100%, 32%, 1);
|
||||||
|
}
|
||||||
.demo-actions {
|
.demo-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|||||||
+12
-4
@@ -31,6 +31,10 @@ const realApi = {
|
|||||||
parseText({ title, text } = {}) {
|
parseText({ title, text } = {}) {
|
||||||
return http.post('/account/entries/parse', { title, text })
|
return http.post('/account/entries/parse', { title, text })
|
||||||
},
|
},
|
||||||
|
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
|
||||||
|
fxRate(from, to = 'KRW') {
|
||||||
|
return http.get('/fx/rate', { params: { from, to } })
|
||||||
|
},
|
||||||
|
|
||||||
// 자주 쓰는 내역(빠른 등록 템플릿)
|
// 자주 쓰는 내역(빠른 등록 템플릿)
|
||||||
quickEntries() {
|
quickEntries() {
|
||||||
@@ -72,8 +76,12 @@ const realApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 예산 (사용자별)
|
// 예산 (사용자별)
|
||||||
budgets() {
|
budgets({ year, month } = {}) {
|
||||||
return http.get('/account/budgets')
|
return http.get('/account/budgets', { params: { year, month } })
|
||||||
|
},
|
||||||
|
// 다른 월 예산을 이 달로 복사(전월 복사 등)
|
||||||
|
copyBudget({ fromYear, fromMonth, toYear, toMonth }) {
|
||||||
|
return http.post('/account/budgets/copy', null, { params: { fromYear, fromMonth, toYear, toMonth } })
|
||||||
},
|
},
|
||||||
budgetStatus({ year, month }) {
|
budgetStatus({ year, month }) {
|
||||||
return http.get('/account/budgets/status', { params: { year, month } })
|
return http.get('/account/budgets/status', { params: { year, month } })
|
||||||
@@ -87,8 +95,8 @@ const realApi = {
|
|||||||
setExpectedIncome({ year, month, expectedIncome }) {
|
setExpectedIncome({ year, month, expectedIncome }) {
|
||||||
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
|
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
|
||||||
},
|
},
|
||||||
createBudget(payload) {
|
createBudget(payload, { year, month } = {}) {
|
||||||
return http.post('/account/budgets', payload)
|
return http.post('/account/budgets', payload, { params: { year, month } })
|
||||||
},
|
},
|
||||||
updateBudget(id, payload) {
|
updateBudget(id, payload) {
|
||||||
return http.put(`/account/budgets/${id}`, payload)
|
return http.put(`/account/budgets/${id}`, payload)
|
||||||
|
|||||||
+3
-8
@@ -1,18 +1,13 @@
|
|||||||
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
|
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { App as CapApp } from '@capacitor/app'
|
import { App as CapApp } from '@capacitor/app'
|
||||||
|
import { handleBack } from '@/composables/appBack'
|
||||||
|
|
||||||
export function setupCapacitor(router) {
|
export function setupCapacitor(router) {
|
||||||
if (!Capacitor.isNativePlatform()) return
|
if (!Capacitor.isNativePlatform()) return
|
||||||
|
|
||||||
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료
|
// 안드로이드 하드웨어 뒤로가기 → 하단 내비 뒤로 버튼과 동일한 통합 처리
|
||||||
CapApp.addListener('backButton', ({ canGoBack }) => {
|
CapApp.addListener('backButton', () => handleBack(router))
|
||||||
if (canGoBack && window.history.length > 1) {
|
|
||||||
router.back()
|
|
||||||
} else {
|
|
||||||
CapApp.exitApp()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
|
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
|
||||||
document.documentElement.classList.add('is-native')
|
document.documentElement.classList.add('is-native')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { handleBack } from '@/composables/appBack'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -9,9 +10,8 @@ const isHome = computed(() => route.path === '/')
|
|||||||
const isSettings = computed(() => route.path.startsWith('/settings'))
|
const isSettings = computed(() => route.path.startsWith('/settings'))
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
|
// 하드웨어 뒤로가기와 동일 동작(오버레이 닫기 → 화면 뒤로 → 최상위 종료 안내)
|
||||||
if (window.history.length > 1) router.back()
|
handleBack(router)
|
||||||
else router.push('/')
|
|
||||||
}
|
}
|
||||||
function goForward() {
|
function goForward() {
|
||||||
router.forward()
|
router.forward()
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { App as CapApp } from '@capacitor/app'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { appLock } from '@/composables/appLock'
|
||||||
|
|
||||||
|
// 통합 뒤로가기 처리 — 하드웨어 뒤로(capacitor.js)와 하단 내비 뒤로 버튼이 동일 동작.
|
||||||
|
// 우선순위:
|
||||||
|
// 1) 잠금 화면이면 우회 불가 → (네이티브) 종료
|
||||||
|
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
|
||||||
|
// 3) 화면 뒤로(히스토리)
|
||||||
|
// 4) 최상위에서 '한 번 더 누르면 종료'(네이티브만, 실수 종료 방지)
|
||||||
|
let exitArmed = false
|
||||||
|
let exitTimer = null
|
||||||
|
|
||||||
|
export function handleBack(router) {
|
||||||
|
const native = Capacitor.isNativePlatform()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
if (appLock.state.locked) {
|
||||||
|
if (native) CapApp.exitApp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui.signupOpen) { ui.closeSignup(); return }
|
||||||
|
if (ui.loginOpen) { ui.closeLogin(); return }
|
||||||
|
if (ui.passwordOpen) { ui.closePassword(); return }
|
||||||
|
if (ui.sidebarOpen) { ui.closeSidebar(); return }
|
||||||
|
|
||||||
|
if (window.history.length > 1) { router.back(); return }
|
||||||
|
|
||||||
|
// 최상위(더 갈 곳 없음)
|
||||||
|
if (native) {
|
||||||
|
if (exitArmed) { CapApp.exitApp(); return }
|
||||||
|
exitArmed = true
|
||||||
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: '한 번 더 누르면 종료됩니다' }))
|
||||||
|
clearTimeout(exitTimer)
|
||||||
|
exitTimer = setTimeout(() => { exitArmed = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
// 전역 치명적 오류 상태. 컴포넌트 트리 밖에서 관리해, 렌더 오류로 화면이
|
||||||
|
// 깨져도(흰 화면) App.vue 가 폴백 UI 를 대신 렌더할 수 있게 한다.
|
||||||
|
export const appError = reactive({ hasError: false })
|
||||||
|
|
||||||
|
export function reportFatal(err) {
|
||||||
|
// 콘솔에는 원본을 남기고, 사용자에겐 폴백 화면을 보여준다.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[fatal]', err)
|
||||||
|
appError.hasError = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFatal() {
|
||||||
|
appError.hasError = false
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ const state = reactive({
|
|||||||
locked: false,
|
locked: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 카메라/갤러리/공유 등 앱 내 네이티브 동작을 호출하면 웹뷰가 잠깐 백그라운드로
|
||||||
|
// 인식돼 잠금이 걸리는 오작동을 막기 위한 "잠금 일시중지" 창(timestamp).
|
||||||
|
let suppressUntil = 0
|
||||||
|
|
||||||
async function hashPin(pin) {
|
async function hashPin(pin) {
|
||||||
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
|
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
|
||||||
const buf = await crypto.subtle.digest('SHA-256', data)
|
const buf = await crypto.subtle.digest('SHA-256', data)
|
||||||
@@ -39,10 +43,19 @@ export const appLock = {
|
|||||||
state.enabled = false
|
state.enabled = false
|
||||||
state.locked = false
|
state.locked = false
|
||||||
},
|
},
|
||||||
/** 잠금 걸기(활성화된 경우에만) */
|
/** 잠금 걸기(활성화 + 일시중지 창 밖일 때만) */
|
||||||
lock() {
|
lock() {
|
||||||
|
if (Date.now() < suppressUntil) return // 네이티브 동작 복귀 — 잠그지 않음
|
||||||
if (state.enabled) state.locked = true
|
if (state.enabled) state.locked = true
|
||||||
},
|
},
|
||||||
|
/** 다음 백그라운드 전환을 잠그지 않음(카메라/갤러리/공유 호출 직전에 사용) */
|
||||||
|
suppressLock(ms = 60000) {
|
||||||
|
suppressUntil = Date.now() + ms
|
||||||
|
},
|
||||||
|
/** 일시중지 창 해제(앱으로 복귀 시) */
|
||||||
|
clearSuppress() {
|
||||||
|
suppressUntil = 0
|
||||||
|
},
|
||||||
/** 잠금 풀기(이번 세션) */
|
/** 잠금 풀기(이번 세션) */
|
||||||
unlock() {
|
unlock() {
|
||||||
state.locked = false
|
state.locked = false
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import router from './router'
|
|||||||
import { setupCapacitor } from './capacitor'
|
import { setupCapacitor } from './capacitor'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useDialog } from './composables/dialog'
|
import { useDialog } from './composables/dialog'
|
||||||
|
import { reportFatal } from './composables/appError'
|
||||||
import { initTheme } from './theme'
|
import { initTheme } from './theme'
|
||||||
|
|
||||||
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
|
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
|
||||||
@@ -22,6 +23,11 @@ window.alert = (message) => {
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
|
||||||
|
app.config.errorHandler = (err, instance, info) => {
|
||||||
|
reportFatal({ err, info })
|
||||||
|
}
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -2,6 +2,7 @@
|
|||||||
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
|
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { accountApi } from '@/api/accountApi'
|
import { accountApi } from '@/api/accountApi'
|
||||||
|
import { appLock } from '@/composables/appLock'
|
||||||
|
|
||||||
async function safe(promise) {
|
async function safe(promise) {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +26,7 @@ async function saveWorkbook(XLSX, wb, name) {
|
|||||||
const { Share } = await import('@capacitor/share')
|
const { Share } = await import('@capacitor/share')
|
||||||
const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache })
|
const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache })
|
||||||
try {
|
try {
|
||||||
|
appLock.suppressLock() // 공유 시트 복귀 시 앱 잠금 오작동 방지
|
||||||
await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri })
|
await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri })
|
||||||
} catch {
|
} catch {
|
||||||
/* 공유 취소 — 파일은 캐시에 저장됨 */
|
/* 공유 취소 — 파일은 캐시에 저장됨 */
|
||||||
@@ -42,7 +44,8 @@ export async function exportBackup() {
|
|||||||
safe(accountApi.wallets()),
|
safe(accountApi.wallets()),
|
||||||
safe(accountApi.categories()),
|
safe(accountApi.categories()),
|
||||||
safe(accountApi.recurrings()),
|
safe(accountApi.recurrings()),
|
||||||
safe(accountApi.budgets()),
|
// 예산은 월별 — 백업엔 현재 월 예산을 담는다
|
||||||
|
safe(accountApi.budgets({ year: new Date().getFullYear(), month: new Date().getMonth() + 1 })),
|
||||||
safe(accountApi.tags()),
|
safe(accountApi.tags()),
|
||||||
safe(accountApi.quickEntries()),
|
safe(accountApi.quickEntries()),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
1<script setup>
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { accountApi } from '@/api/accountApi'
|
import { accountApi } from '@/api/accountApi'
|
||||||
import { reminders } from '@/native/reminders'
|
import { reminders } from '@/native/reminders'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDialog } from '@/composables/dialog'
|
import { useDialog } from '@/composables/dialog'
|
||||||
|
import { appLock } from '@/composables/appLock'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||||
import { cardNotif } from '@/native/cardNotif'
|
import { cardNotif } from '@/native/cardNotif'
|
||||||
@@ -83,13 +84,99 @@ function resetFilters() {
|
|||||||
// 추가/수정 모달
|
// 추가/수정 모달
|
||||||
const formOpen = ref(false)
|
const formOpen = ref(false)
|
||||||
const editId = ref(null)
|
const editId = ref(null)
|
||||||
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' })
|
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
|
||||||
|
|
||||||
|
// ===== 외화 결제 =====
|
||||||
|
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
|
||||||
|
const isForeign = computed(() => form.currency && form.currency !== 'KRW')
|
||||||
|
// JPY 등은 원본이 정수 위주라 100단위 표기가 흔하지만, 환율은 1단위 기준으로 통일
|
||||||
|
const convertedKrw = computed(() =>
|
||||||
|
isForeign.value ? Math.round((Number(form.foreignAmount) || 0) * (Number(form.rate) || 0)) : null,
|
||||||
|
)
|
||||||
|
// 외화면 환산 원화를 amount 에 반영(저장·검증·표시는 원화 기준)
|
||||||
|
watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
|
||||||
|
if (isForeign.value) form.amount = convertedKrw.value
|
||||||
|
})
|
||||||
|
const fxLoading = ref(false)
|
||||||
|
async function fetchRate() {
|
||||||
|
if (!isForeign.value || fxLoading.value) return
|
||||||
|
fxLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await accountApi.fxRate(form.currency, 'KRW')
|
||||||
|
// 환율은 소수점 4자리로 통일(입력폼 step·저장 정밀도 일치)
|
||||||
|
if (res?.rate != null) form.rate = Math.round(Number(res.rate) * 10000) / 10000
|
||||||
|
else formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
|
||||||
|
} catch {
|
||||||
|
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
|
||||||
|
} finally {
|
||||||
|
fxLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onCurrencyChange() {
|
||||||
|
if (isForeign.value) {
|
||||||
|
if (form.foreignAmount == null && form.amount != null) form.foreignAmount = form.amount // 원화→외화 전환 시 초기값
|
||||||
|
fetchRate()
|
||||||
|
} else {
|
||||||
|
form.foreignAmount = null
|
||||||
|
form.rate = null
|
||||||
|
}
|
||||||
|
}
|
||||||
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
||||||
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
|
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
|
||||||
const repayTargetIsCard = computed(() => {
|
const repayTargetIsCard = computed(() => {
|
||||||
const w = wallets.value.find((x) => x.id === form.toWalletId)
|
const w = wallets.value.find((x) => x.id === form.toWalletId)
|
||||||
return !!w && w.type === 'CARD'
|
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개월, 일시불은 빈값)
|
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
|
||||||
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
|
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
|
||||||
const installmentMonthly = computed(() => {
|
const installmentMonthly = computed(() => {
|
||||||
@@ -136,6 +223,7 @@ async function chooseGallery() {
|
|||||||
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
|
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
|
||||||
async function captureFrom(sourceKey) {
|
async function captureFrom(sourceKey) {
|
||||||
try {
|
try {
|
||||||
|
appLock.suppressLock() // 카메라 복귀 시 앱 잠금 오작동 방지
|
||||||
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
|
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
|
||||||
const photo = await Camera.getPhoto({
|
const photo = await Camera.getPhoto({
|
||||||
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
|
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
|
||||||
@@ -209,6 +297,8 @@ const WALLET_KINDS = [
|
|||||||
]
|
]
|
||||||
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
||||||
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
||||||
|
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
|
||||||
|
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
|
||||||
function walletKindOf(id) {
|
function walletKindOf(id) {
|
||||||
const w = wallets.value.find((x) => x.id === id)
|
const w = wallets.value.find((x) => x.id === id)
|
||||||
return w ? w.type : ''
|
return w ? w.type : ''
|
||||||
@@ -236,25 +326,39 @@ 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 []
|
// 대분류를 4개씩 행으로 묶음
|
||||||
return categories.value.filter((c) => c.parentId === major.id).map((c) => c.name)
|
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 로부터 대분류 추론 (편집 진입 시)
|
// form.category 로부터 대분류 추론 (편집 진입 시)
|
||||||
function syncCategoryMajor() {
|
function syncCategoryMajor() {
|
||||||
const cur = form.category
|
const cur = form.category
|
||||||
@@ -270,19 +374,25 @@ 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))])
|
||||||
|
|
||||||
// 모달 내 분류 인라인 추가
|
// 모달 내 분류 인라인 추가
|
||||||
const addingCategory = ref(false)
|
const addingCategory = ref(false)
|
||||||
|
const addingAsMajor = ref(true) // true=대분류 추가, false=소분류 추가
|
||||||
const newCategoryName = ref('')
|
const newCategoryName = ref('')
|
||||||
const catSubmitting = ref(false)
|
const catSubmitting = ref(false)
|
||||||
function startAddCategory() {
|
function startAddMajorCategory() {
|
||||||
|
categoryMajor.value = ''
|
||||||
|
form.category = ''
|
||||||
newCategoryName.value = ''
|
newCategoryName.value = ''
|
||||||
|
addingAsMajor.value = true
|
||||||
|
addingCategory.value = true
|
||||||
|
}
|
||||||
|
function startAddSubCategory() {
|
||||||
|
newCategoryName.value = ''
|
||||||
|
addingAsMajor.value = false
|
||||||
addingCategory.value = true
|
addingCategory.value = true
|
||||||
}
|
}
|
||||||
function cancelAddCategory() {
|
function cancelAddCategory() {
|
||||||
@@ -450,7 +560,7 @@ function todayStr() {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
editId.value = null
|
editId.value = null
|
||||||
editingPending.value = false
|
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: '' })
|
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 = []
|
selectedTagIds.value = []
|
||||||
syncCategoryMajor()
|
syncCategoryMajor()
|
||||||
cancelAddCategory()
|
cancelAddCategory()
|
||||||
@@ -479,6 +589,9 @@ function openEdit(e) {
|
|||||||
interest: null,
|
interest: null,
|
||||||
annualFee: null,
|
annualFee: null,
|
||||||
installmentMonths: e.installmentMonths || '',
|
installmentMonths: e.installmentMonths || '',
|
||||||
|
currency: e.currency && e.currency !== 'KRW' ? e.currency : 'KRW',
|
||||||
|
foreignAmount: e.currency && e.currency !== 'KRW' ? Number(e.originalAmount) : null,
|
||||||
|
rate: e.currency && e.currency !== 'KRW' ? Number(e.exchangeRate) : null,
|
||||||
})
|
})
|
||||||
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
||||||
const nameToId = {}
|
const nameToId = {}
|
||||||
@@ -557,6 +670,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const foreign = isForeign.value
|
||||||
const payload = {
|
const payload = {
|
||||||
entryDate: form.entryDate,
|
entryDate: form.entryDate,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
@@ -567,6 +681,10 @@ async function submit() {
|
|||||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||||
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
|
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
|
||||||
tagIds: selectedTagIds.value,
|
tagIds: selectedTagIds.value,
|
||||||
|
// 외화 결제: amount 는 환산 원화, 원본은 별도 보존
|
||||||
|
currency: foreign ? form.currency : null,
|
||||||
|
originalAmount: foreign ? Number(form.foreignAmount) : null,
|
||||||
|
exchangeRate: foreign ? Number(form.rate) : null,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
@@ -945,7 +1063,8 @@ onMounted(async () => {
|
|||||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2">
|
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length) || (e.currency && e.currency !== 'KRW')" class="ei-line2">
|
||||||
|
<span v-if="e.currency && e.currency !== 'KRW'" class="ei-fx">{{ e.currency }} {{ Number(e.originalAmount).toLocaleString('ko-KR') }} @{{ Number(e.exchangeRate).toLocaleString('ko-KR') }}</span>
|
||||||
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · 월 {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
|
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · 월 {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
|
||||||
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
|
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
|
||||||
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
||||||
@@ -965,8 +1084,8 @@ onMounted(async () => {
|
|||||||
<!-- 추가/수정 모달 -->
|
<!-- 추가/수정 모달 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
|
||||||
<div class="modal" role="dialog" aria-modal="true">
|
<div class="modal entry-modal" role="dialog" aria-modal="true">
|
||||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||||
|
|
||||||
@@ -1022,9 +1141,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
<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' ? '계좌/카드 선택' : '출금 계좌 선택') }}</option>
|
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option>
|
||||||
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
{{ w.name || w.issuer }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1050,7 +1169,7 @@ onMounted(async () => {
|
|||||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||||
<option value="">입금 계좌 선택</option>
|
<option value="">입금 계좌 선택</option>
|
||||||
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
{{ w.name || w.issuer }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1062,38 +1181,97 @@ onMounted(async () => {
|
|||||||
<select v-model="form.toWalletId" :disabled="submitting">
|
<select v-model="form.toWalletId" :disabled="submitting">
|
||||||
<option value="">(선택)</option>
|
<option value="">(선택)</option>
|
||||||
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
|
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
|
||||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
{{ w.name || w.issuer }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
|
||||||
<label>이자<input v-model.number="form.interest" 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>
|
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
|
||||||
</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-picker">
|
||||||
<select v-model="categoryMajor" :disabled="submitting" @change="onMajorChange" title="대분류">
|
<!-- 대분류 행별 렌더링: 선택된 대분류가 속한 행 바로 아래 소분류 삽입 -->
|
||||||
<option value="">대분류 선택</option>
|
<template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
|
||||||
<option v-for="m in majorOptions" :key="m" :value="m">{{ m }}</option>
|
<div class="acc-major-grid">
|
||||||
</select>
|
<button
|
||||||
<select v-if="subOptions.length" v-model="form.category" :disabled="submitting" title="소분류">
|
v-for="m in row" :key="m.id"
|
||||||
<option value="">소분류 선택</option>
|
type="button" class="acc-chip major"
|
||||||
<option v-for="s in subOptions" :key="s" :value="s">{{ s }}</option>
|
:class="{ active: categoryMajor === m.name }"
|
||||||
</select>
|
:disabled="submitting"
|
||||||
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
|
@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>
|
||||||
<div v-else class="cat-input">
|
<div v-else class="cat-input">
|
||||||
<input
|
<input
|
||||||
v-model="newCategoryName" type="text"
|
v-model="newCategoryName" type="text"
|
||||||
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
|
:placeholder="addingAsMajor ? (form.type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류') : `${categoryMajor} > 새 소분류`"
|
||||||
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
|
: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 primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
|
||||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
|
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
<label v-if="!isRepayment" class="amount-label">
|
||||||
|
금액
|
||||||
|
<div class="amount-row">
|
||||||
|
<select v-model="form.currency" class="cur-sel" :disabled="submitting" @change="onCurrencyChange">
|
||||||
|
<option v-for="c in CURRENCIES" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-if="!isForeign" v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" />
|
||||||
|
<input v-else v-model.number="form.foreignAmount" type="number" min="0" step="0.01" :placeholder="form.currency + ' 금액'" :disabled="submitting" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div v-if="!isRepayment && isForeign" class="fx-row">
|
||||||
|
<div class="fx-rate-field">
|
||||||
|
<span class="fx-label">환율(1 {{ form.currency }})</span>
|
||||||
|
<div class="fx-rate-input">
|
||||||
|
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
|
||||||
|
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '…' : '↻ 자동' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="fx-krw">= ₩{{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
|
||||||
|
</div>
|
||||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||||
|
|
||||||
<div v-if="!isRepayment && isPremium" class="tag-field">
|
<div v-if="!isRepayment && isPremium" class="tag-field">
|
||||||
@@ -1595,6 +1773,27 @@ button.primary {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
|
||||||
|
.entry-backdrop {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.modal.entry-modal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding-top: calc(1.5rem + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
/* 넓은 화면(PC)에서도 입력 내용은 읽기 좋은 폭으로 가운데 정렬 */
|
||||||
|
.modal.entry-modal > h2,
|
||||||
|
.modal.entry-modal .entry-form {
|
||||||
|
max-width: 460px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
.modal {
|
.modal {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1782,13 +1981,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;
|
||||||
@@ -1803,6 +1995,78 @@ 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-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 {
|
.tag-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1851,6 +2115,68 @@ button.primary {
|
|||||||
color: hsla(160, 100%, 37%, 1);
|
color: hsla(160, 100%, 37%, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* 외화 표기(목록 상세) */
|
||||||
|
.ei-fx {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border: 1px solid hsla(40, 90%, 50%, 0.6);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #b8860b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 입력폼 금액/환율 */
|
||||||
|
.amount-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.cur-sel {
|
||||||
|
flex: none;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
.amount-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fx-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: -0.3rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
.fx-rate-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.fx-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
/* input 과 버튼을 한 행으로 — stretch 로 버튼 높이를 input 에 맞춤 */
|
||||||
|
.fx-rate-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.fx-rate-input input {
|
||||||
|
width: 7rem;
|
||||||
|
}
|
||||||
|
.fx-auto {
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fx-krw {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b8860b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
.row-wallet {
|
.row-wallet {
|
||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const form = reactive({
|
|||||||
openingBalance: 0,
|
openingBalance: 0,
|
||||||
openingDate: '',
|
openingDate: '',
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
|
// 대출(LOAN) 전용
|
||||||
|
loanAmount: null,
|
||||||
|
loanRate: null,
|
||||||
|
loanMethod: '',
|
||||||
|
loanMonths: null,
|
||||||
|
loanStart: '',
|
||||||
})
|
})
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formError = ref(null)
|
const formError = ref(null)
|
||||||
@@ -196,7 +202,7 @@ function issuerLabel(t) {
|
|||||||
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||||
}
|
}
|
||||||
function openingLabel(t) {
|
function openingLabel(t) {
|
||||||
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)'
|
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
|
||||||
}
|
}
|
||||||
// 투자 수익률(%) — 투입원금 대비 평가손익
|
// 투자 수익률(%) — 투입원금 대비 평가손익
|
||||||
function returnPct(w) {
|
function returnPct(w) {
|
||||||
@@ -229,6 +235,11 @@ function openCreate() {
|
|||||||
openingBalance: 0,
|
openingBalance: 0,
|
||||||
openingDate: '',
|
openingDate: '',
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
|
loanAmount: null,
|
||||||
|
loanRate: null,
|
||||||
|
loanMethod: '',
|
||||||
|
loanMonths: null,
|
||||||
|
loanStart: '',
|
||||||
})
|
})
|
||||||
formError.value = null
|
formError.value = null
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
@@ -245,6 +256,11 @@ function openEdit(w) {
|
|||||||
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
||||||
openingDate: w.openingDate || '',
|
openingDate: w.openingDate || '',
|
||||||
currentValue: w.currentValue ?? null,
|
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
|
formError.value = null
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
@@ -258,6 +274,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
const magnitude = Number(form.openingBalance) || 0
|
const magnitude = Number(form.openingBalance) || 0
|
||||||
|
const isLoan = form.type === 'LOAN'
|
||||||
const payload = {
|
const payload = {
|
||||||
type: form.type,
|
type: form.type,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -271,6 +288,11 @@ async function submit() {
|
|||||||
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
||||||
? Number(form.currentValue)
|
? Number(form.currentValue)
|
||||||
: null,
|
: 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 {
|
try {
|
||||||
if (editId.value) await accountApi.updateWallet(editId.value, payload)
|
if (editId.value) await accountApi.updateWallet(editId.value, payload)
|
||||||
@@ -297,6 +319,9 @@ async function remove(w) {
|
|||||||
function cardTypeLabel(v) {
|
function cardTypeLabel(v) {
|
||||||
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
|
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
|
||||||
}
|
}
|
||||||
|
function loanMethodLabel(v) {
|
||||||
|
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
@@ -362,6 +387,10 @@ onBeforeUnmount(() => sortable?.destroy())
|
|||||||
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
|
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="balance-wrap">
|
<div class="balance-wrap">
|
||||||
@@ -458,6 +487,33 @@ onBeforeUnmount(() => sortable?.destroy())
|
|||||||
</template>
|
</template>
|
||||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
<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>
|
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async function load() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const params = { year: year.value, month: month.value }
|
const params = { year: year.value, month: month.value }
|
||||||
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
|
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets(params)])
|
||||||
statuses.value = st
|
statuses.value = st
|
||||||
budgetsRaw.value = raw
|
budgetsRaw.value = raw
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -154,6 +154,46 @@ function nextMonth() {
|
|||||||
loadIncome()
|
loadIncome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 월별 예산 복사 =====
|
||||||
|
const copying = ref(false)
|
||||||
|
const ym = (y, m) => `${y}.${String(m).padStart(2, '0')}`
|
||||||
|
async function copyToNextMonth() {
|
||||||
|
if (copying.value || !budgetsRaw.value.length) return
|
||||||
|
const to = month.value === 12 ? { y: year.value + 1, m: 1 } : { y: year.value, m: month.value + 1 }
|
||||||
|
const ok = await dialog.confirm(
|
||||||
|
`${ym(year.value, month.value)} 예산을 ${ym(to.y, to.m)}(다음 달)로 복사할까요?\n다음 달의 같은 분류 예산은 덮어씁니다.`,
|
||||||
|
{ title: '다음 달로 복사' },
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
copying.value = true
|
||||||
|
try {
|
||||||
|
await accountApi.copyBudget({ fromYear: year.value, fromMonth: month.value, toYear: to.y, toMonth: to.m })
|
||||||
|
await dialog.alert(`${ym(to.y, to.m)}로 예산을 복사했어요.`)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || '복사에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
copying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function copyFromPrevMonth() {
|
||||||
|
if (copying.value) return
|
||||||
|
const prev = month.value === 1 ? { y: year.value - 1, m: 12 } : { y: year.value, m: month.value - 1 }
|
||||||
|
const ok = await dialog.confirm(
|
||||||
|
`전월(${ym(prev.y, prev.m)}) 예산을 ${ym(year.value, month.value)}로 가져올까요?\n이 달의 같은 분류 예산은 덮어씁니다.`,
|
||||||
|
{ title: '전월 예산 복사' },
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
copying.value = true
|
||||||
|
try {
|
||||||
|
await accountApi.copyBudget({ fromYear: prev.y, fromMonth: prev.m, toYear: year.value, toMonth: month.value })
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || '복사에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
copying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editId.value = null
|
editId.value = null
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -213,7 +253,7 @@ async function submit() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editId.value) await accountApi.updateBudget(editId.value, payload)
|
if (editId.value) await accountApi.updateBudget(editId.value, payload)
|
||||||
else await accountApi.createBudget(payload)
|
else await accountApi.createBudget(payload, { year: year.value, month: month.value })
|
||||||
formOpen.value = false
|
formOpen.value = false
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -252,6 +292,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 월별 예산 복사 -->
|
||||||
|
<div class="copy-actions">
|
||||||
|
<button type="button" class="copy-btn" :disabled="copying" @click="copyFromPrevMonth">↤ 전월 예산 가져오기</button>
|
||||||
|
<button type="button" class="copy-btn" :disabled="copying || !budgetsRaw.length" @click="copyToNextMonth">다음 달로 복사 ↦</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 월 예상 수입 vs 총 예산 -->
|
<!-- 월 예상 수입 vs 총 예산 -->
|
||||||
<div class="income-panel">
|
<div class="income-panel">
|
||||||
<div class="inc-input">
|
<div class="inc-input">
|
||||||
@@ -431,6 +477,26 @@ button.primary {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
/* 예상 수입 vs 예산 */
|
/* 예상 수입 vs 예산 */
|
||||||
|
.copy-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: -0.4rem 0 1rem;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.copy-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.income-panel {
|
.income-panel {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -62,15 +62,22 @@ async function reload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
|
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
|
||||||
|
// 스크롤하다 실수로 순서가 바뀌는 것 방지: 기본은 드래그 비활성, '순서변경' 버튼으로 켠다.
|
||||||
const majorListEl = ref(null)
|
const majorListEl = ref(null)
|
||||||
|
const reorderMode = ref(false)
|
||||||
|
|
||||||
let sortables = []
|
let sortables = []
|
||||||
function destroySortables() {
|
function destroySortables() {
|
||||||
sortables.forEach((s) => s.destroy())
|
sortables.forEach((s) => s.destroy())
|
||||||
sortables = []
|
sortables = []
|
||||||
}
|
}
|
||||||
|
function toggleReorder() {
|
||||||
|
reorderMode.value = !reorderMode.value
|
||||||
|
nextTick(() => initSortable()) // 켜면 생성, 끄면 destroy 후 early-return
|
||||||
|
}
|
||||||
function initSortable() {
|
function initSortable() {
|
||||||
destroySortables()
|
destroySortables()
|
||||||
if (!majorListEl.value) return
|
if (!majorListEl.value || !reorderMode.value) return
|
||||||
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
|
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
|
||||||
sortables.push(
|
sortables.push(
|
||||||
Sortable.create(majorListEl.value, {
|
Sortable.create(majorListEl.value, {
|
||||||
@@ -189,7 +196,12 @@ onBeforeUnmount(destroySortables)
|
|||||||
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
|
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
|
||||||
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
|
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tabs-actions">
|
||||||
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
|
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
|
||||||
|
<button type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
|
||||||
|
{{ reorderMode ? '완료' : '순서변경' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="new-cat" @submit.prevent="addCategory">
|
<form class="new-cat" @submit.prevent="addCategory">
|
||||||
@@ -202,7 +214,10 @@ onBeforeUnmount(destroySortables)
|
|||||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
|
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="hint sm">≡ 손잡이로 순서 변경 — <b>대분류</b>는 소분류와 함께, <b>소분류</b>는 그 대분류 안에서만 이동합니다.</p>
|
<p class="hint sm">
|
||||||
|
<template v-if="reorderMode">≡ 손잡이를 끌어 순서를 바꾸세요 — <b>대분류</b>는 소분류와 함께, <b>소분류</b>는 그 대분류 안에서만 이동. 끝나면 <b>완료</b>.</template>
|
||||||
|
<template v-else><b>순서변경</b>을 누르면 분류 순서를 바꿀 수 있어요. (평소엔 스크롤 중 실수 방지로 잠겨 있어요)</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p v-if="error" class="msg error">{{ error }}</p>
|
<p v-if="error" class="msg error">{{ error }}</p>
|
||||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||||
@@ -211,7 +226,7 @@ onBeforeUnmount(destroySortables)
|
|||||||
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
|
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
|
||||||
<div class="cat-row major-row">
|
<div class="cat-row major-row">
|
||||||
<div class="cat-main">
|
<div class="cat-main">
|
||||||
<span class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)">≡</span>
|
<span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)">≡</span>
|
||||||
<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)" />
|
||||||
<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)" />
|
||||||
@@ -220,7 +235,7 @@ onBeforeUnmount(destroySortables)
|
|||||||
<ul 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 class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
||||||
<span class="sub-mark">↳</span>
|
<span class="sub-mark">↳</span>
|
||||||
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
|
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
|
||||||
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
|
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
|
||||||
@@ -230,9 +245,12 @@ onBeforeUnmount(destroySortables)
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!loading && !tree.length" class="msg">
|
<div v-if="!loading && !tree.length" class="empty-state">
|
||||||
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
|
<p class="empty-emoji">🗂️</p>
|
||||||
</p>
|
<p class="empty-title">등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요</p>
|
||||||
|
<p class="empty-desc">기본 분류를 불러오면 식비·교통 등이 한 번에 추가됩니다.</p>
|
||||||
|
<button type="button" class="empty-cta" @click="importDefaults">기본 분류 불러오기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 인앱 확인 모달 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
<!-- 인앱 확인 모달 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -299,9 +317,23 @@ button.danger {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
|
||||||
}
|
}
|
||||||
.tabs-row .tabs {
|
.tabs-row .tabs {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
flex: none; /* 탭은 줄어들지 않음 */
|
||||||
|
}
|
||||||
|
.tabs-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.reorder-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -315,6 +347,7 @@ button.danger {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
|
white-space: nowrap; /* '지출 분류' 두 줄로 깨짐 방지 */
|
||||||
}
|
}
|
||||||
.tabs button.active {
|
.tabs button.active {
|
||||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||||
@@ -411,6 +444,7 @@ button.danger {
|
|||||||
.sub-mark {
|
.sub-mark {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint.sm {
|
.hint.sm {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
@@ -421,6 +455,39 @@ button.danger {
|
|||||||
.msg.error {
|
.msg.error {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
/* 신규 유저 빈 상태 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
.empty-emoji {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
.empty-desc {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.empty-cta {
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
padding: 0.7rem 1.6rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.empty-cta:hover {
|
||||||
|
background: hsla(160, 100%, 32%, 1);
|
||||||
|
}
|
||||||
/* 인앱 확인 모달 */
|
/* 인앱 확인 모달 */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user