Compare commits
90 Commits
42406b4c75
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d79fa040 | |||
| f738c6f5b1 | |||
| 75587e7645 | |||
| f50f1b8fc5 | |||
| f1dc11d402 | |||
| f367379c84 | |||
| 42cfeccbaf | |||
| 466fbe8136 | |||
| 1a85e448aa | |||
| 00d2945e39 | |||
| 24db9b0587 | |||
| 6f49a7eef0 | |||
| cdaa7d09ac | |||
| 2ee5cae6ea | |||
| 2d70fced79 | |||
| ecc3ef0f4e | |||
| c7875b0577 | |||
| d20d3c3027 | |||
| ed607e1cda | |||
| 641d0649ae | |||
| a4e0b38b2f | |||
| ca09dce2bd | |||
| 13a0f0d40f | |||
| 28712a026d | |||
| d63c6997c9 | |||
| d8326568ff | |||
| 486942b1c9 | |||
| ba7ffaff92 | |||
| 5567a239df | |||
| 2b58e976fc | |||
| e4ab683ac8 | |||
| a5264f4dff | |||
| 18f015f7fd | |||
| b039069d6c | |||
| a42715834b | |||
| d40c75d3e5 | |||
| fcd3f23a32 | |||
| 1ee264f2ab | |||
| 98ad7b9e98 | |||
| b70742b79c | |||
| 5beb999535 | |||
| 654fc44f48 | |||
| 061fb47b97 | |||
| e95e14378b | |||
| f446fb3f03 | |||
| 4d8881342a | |||
| c694dd9d04 | |||
| ec130e85a6 | |||
| 1169f3e542 | |||
| 3429ddc235 | |||
| d84101ecda | |||
| d7cddba77c | |||
| e655e79133 | |||
| 17e7dd53a6 | |||
| 1f128940f5 | |||
| 951cbdce34 | |||
| 1b2d6a2c02 | |||
| 810a5d9380 | |||
| e2d9b8ea37 | |||
| cdf94b75cb | |||
| e3ab2f9f3c | |||
| 769aa38b52 | |||
| d5b4c46163 | |||
| 5b46ba93f3 | |||
| a52ac6532c | |||
| 253e88e0fa | |||
| 296ae594f8 | |||
| ac20174378 | |||
| 87c7695cc3 | |||
| 9d1ae0e402 | |||
| fb4accdca6 | |||
| 03e11cc79c | |||
| d1dd19d5a3 | |||
| f3f11fa15b | |||
| 4c325544a6 | |||
| 0090c31294 | |||
| 59a17f9087 | |||
| e9e397434f | |||
| 9f9fc14c64 | |||
| a9d4ea8410 | |||
| 7affe5c318 | |||
| 5660451f04 | |||
| c069e70de4 | |||
| e23a694675 | |||
| 7e13a42848 | |||
| 6a4d01b6e1 | |||
| 9994456f30 | |||
| 066ada961a | |||
| a561eccef8 | |||
| 902b213f60 |
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
@@ -46,10 +46,12 @@ npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예
|
||||
|
||||
## 주요 기능
|
||||
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
|
||||
- **AI 보조** — 자연어 한 줄 입력("어제 스타벅스 5천원"), 영수증 사진 구조화, 분류·계좌 자동 추천, 통계 화면 재무 코멘트·절약 가이드 (Claude Haiku, 집계만 전송·저장은 사용자 확인 후)
|
||||
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
|
||||
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
|
||||
- **통계** — 분류별 분석, 순자산 추이, 월말 예상 지출, 지난달 대비 (대시보드 차트)
|
||||
- **통계** — '이번 달 현황'(예산·수입 대비 통합), 분류별 분석, 순자산 추이, 월말 예상 지출(일회성 큰 지출 제외 결정적 계산), 지난달 대비
|
||||
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
|
||||
- **활동 포인트** — 가계부·게시판 활동 적립, 한 달 개근(모든 날 기록) 랜덤 보너스
|
||||
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
|
||||
- **테마** — 시스템/라이트/다크 모드
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ android {
|
||||
applicationId "kr.sblog.slimbudget"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 3
|
||||
versionName "1.0.2"
|
||||
versionCode 6
|
||||
versionName "1.0.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+81
-25
@@ -1,6 +1,6 @@
|
||||
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
|
||||
|
||||
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
|
||||
> 최종 업데이트: 2026-07-11. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
|
||||
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
|
||||
|
||||
---
|
||||
@@ -33,7 +33,7 @@
|
||||
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
|
||||
|
||||
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
|
||||
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
|
||||
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 정기결제 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
|
||||
|
||||
### 등록 문서
|
||||
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
|
||||
@@ -52,14 +52,26 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚧 현재 상태 (2026-06-30)
|
||||
## 🚧 현재 상태 (2026-07-05)
|
||||
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
|
||||
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
|
||||
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
|
||||
- ✅ **비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
|
||||
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
|
||||
- ✅ Google Search Console sblog.kr 도메인 소유권 인증 완료 (DNS TXT)
|
||||
- ⏳ **테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
|
||||
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
|
||||
- ⏳ **v4 AAB 빌드 완료** (versionCode 4, 1.0.3) → 비공개 트랙 업로드 대기
|
||||
- ⏳ 개인사업자 계정 전환 → DUNS 신청 완료(D&B 발급 대기) → 발급 후 Play Console에서 마저 진행
|
||||
- Play Console 웹사이트 인증(www.sblog.kr) 완료
|
||||
|
||||
### 🍎 iOS(App Store) 트랙 — 2026-07-11
|
||||
- ✅ **애플 개발자 등록 완료** (Team ID `G9PDM2C4L6`)
|
||||
- ✅ **애플 로그인(Sign in with Apple) 실기기 동작 확인** — App Store 4.8 요건 충족
|
||||
- ✅ **iOS 번들ID `kr.sblog.slimbudget.app`** (기존 `kr.sblog.slimbudget`는 타 계정 선점 → iOS 전용 새 ID, 안드로이드는 그대로)
|
||||
- App ID + Sign in with Apple/Push capability, `CODE_SIGN_ENTITLEMENTS` 연결, `ITSAppUsesNonExemptEncryption=false`
|
||||
- ✅ **App Store Connect 앱 생성 + v1.0 (build 1) 심사 제출 완료** — 연령 15+, App Privacy(이메일·사용자콘텐츠·사용자ID / 추적없음 / 앱기능), 무료, 콘텐츠권한 없음, 스크린샷(6.5" 1242×2688 iPhone + iPad 13")
|
||||
- 로그인은 구글/애플 소셜 전용 → 심사 메모에 **둘러보기(데모)** 안내
|
||||
- ⏳ **심사 결과 대기** (승인 시 출시 / 거부 시 Resolution Center 대응)
|
||||
|
||||
---
|
||||
|
||||
@@ -67,7 +79,7 @@
|
||||
|
||||
1. ⏳ **테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
|
||||
2. ⏳ **14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
|
||||
3. **v3 AAB 업로드** — 이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
|
||||
3. ⏳ **v4 AAB 비공개 트랙 업로드** — 빌드 완료(versionCode 4, 1.0.3), Play Console 업로드 후 검토 제출 (14일 카운트 리셋 안 됨)
|
||||
4. 승인 → **프로덕션 출시**
|
||||
|
||||
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
|
||||
@@ -96,6 +108,7 @@
|
||||
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
|
||||
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
|
||||
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
|
||||
- **외화 환율 갱신 시각** — 환율 자동조회 후 '방금 / N분 전 / N시간 전' 레이블(30초 ticker), FxController `updatedAt` 응답 포함
|
||||
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
|
||||
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
|
||||
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
|
||||
@@ -106,37 +119,80 @@
|
||||
- `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지
|
||||
- `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
|
||||
- `CategoryView` 리스트 UI → **칩 그리드 관리 모드** (CategoryPicker admin prop): ✏ 이름수정·✕ 삭제·⠿ 순서변경(토글 후 드래그)
|
||||
- **게시판 포인트 정책** — 작성 10P(일 5회·50P 한도), 스팸 키워드 포함 시 미지급; 추천 +5P(작성자·일 50P)/비추 -1P; 남글추천 +1P(투표자·일 10P)/취소 -1P
|
||||
- **가계부 작성 포인트** — 건당 10P, 일 5회(50P) 한도
|
||||
- **스팸 키워드 자동 블라인드** — 14개 키워드 포함 시 글/댓글 즉시 블라인드, 포인트 미지급
|
||||
- **관리자 신고글 목록 고도화** — 신고 누적 + 스팸 감지 통합; 🤖 스팸감지 뱃지·blockReason 표시; 스팸 항목 신고 무시 버튼 숨김
|
||||
|
||||
**2026-07-09~11 추가 (웹·서버 배포 완료 / iOS는 재빌드 시 반영)**
|
||||
- **애플 로그인(Sign in with Apple)** — 프런트(`native/appleAuth`·LoginModal)·백엔드(`/auth/apple`, 애플 JWKS 검증·aud=번들ID)·DB(`member.apple_id`)·iOS entitlements 전 구간. iOS 번들ID `kr.sblog.slimbudget.app`로 변경, 서버 `apple-client-id` 정합. 실기기 로그인 확인
|
||||
- **입력 UI 모달 → 페이지 전환 + 바텀시트** (톤 통일)
|
||||
- **계좌**(`WalletFormView`)·**정기결제**(`RecurringFormView`) → 별도 라우트 페이지, 생성 시 계좌 종류 선택 복원
|
||||
- **내역·예산** → 콘텐츠 영역 인라인 페이지(`?entry`/`?budget` 라우트 바인딩, 뒤로가기로 닫힘) — 핵심 로직(AI·OCR·상환 등) 이관 없이 프레젠테이션만 변경(회귀 위험 최소화)
|
||||
- 선택 항목 **바텀시트**: `BottomSheet`·`SheetSelect`·**`AccountPicker`(종류→계좌 아코디언)** 공통 컴포넌트. 계좌·분류·검색 필터에 적용(분류는 CategoryPicker `hideAdd`)
|
||||
- **화면 헤더 뒤로가기 버튼 제거** → 하단 네비 뒤로가기로 유도
|
||||
- **계좌 관리 개편** — 탭 제거→종류별 그룹 목록, 계좌명 클릭 시 **상세 화면**(`AccountWalletDetailView`), 순서변경 잠금 토글, 투자계좌는 투자금만
|
||||
- **대출·카드할부 상환 조회(유료)** `RepaymentScheduleView`+`utils/repayment.js` — 상환방식별 상각+카드할부(무이자), 회차별 원금·이자·합계·잔액. **대출 월 상환금(loanPayment) 필드**로 은행 조회액과 정확 일치. 이자율 소수점 3자리. 홈 **'이번 달 상환 예정' 카드(유료)**
|
||||
- **예산 단순화** — '월 예산'만 등록, 고정/비고정·일/주/년·**1회성 예산 제거**
|
||||
- **레이아웃** — 하단 고정 내비에 가리던 문제 수정(본문 `padding-bottom`)
|
||||
- **정기결제 등록 자동세팅** — 원리금균등은 저장된 월 상환금 우선
|
||||
- **연락처 이메일** → `dondwaeji.app@gmail.com` (약관·계정삭제·스토어 문의)
|
||||
|
||||
**2026-07-07 추가 (웹·서버 배포 완료)**
|
||||
- **정기결제 리네이밍** — 전체 화면 '고정지출' → '정기결제' (백업 엑셀 시트명은 기존 백업 복원 호환 위해 유지)
|
||||
- **정기결제 주기 월/년만** — DAILY·WEEKLY 제거, 요일선택 UI 제거 (`RecurringView`)
|
||||
- **계좌 관리 화면 개편** — 탭 제거 → **종류별 그룹 목록**, 계좌명 클릭 시 **상세 화면 분리**(`/account/wallets/:id` `AccountWalletDetailView`), 순서변경 **잠금 토글**, 투자계좌는 투자금만 표기·상세 비활성
|
||||
- **태그관리 순서변경 토글** — 기본 비활성, '순서변경' 버튼 클릭 시 드래그 활성 (`AccountTagView`)
|
||||
- **대출·카드할부 상환 조회 (유료)** — `RepaymentScheduleView.vue` + `utils/repayment.js`. 대출 상환방식별 상각(원리금균등·원금균등·만기일시) + 카드 할부(무이자) **남은 스케줄**, 회차별 **원금·이자·합계·남은잔액**, 이번 달/남은 총액. 사이드바 유료 메뉴
|
||||
- **대출 월 상환금(loanPayment) 필드** — 백엔드 컬럼(`wallet.loan_payment`)+DTO/매퍼, 계좌 폼 선택 입력. 원리금균등을 **은행 조회액과 정확히 일치**(이자=잔액×이율, 원금=월상환금−이자), **마지막 회차 잔여 반올림 정산**. 미입력 시 잔액·개월수로 PMT 추정
|
||||
- **대출 이자율 소수점 3자리** 입력(step 0.001, `loan_rate` DECIMAL(7,4))
|
||||
- **가계부 대출 상환 자동세팅** — 원리금균등 선택 시 저장된 **월 상환금 우선** 반영(옛 PMT 추정 대체), 마지막 회차 초과상환 방지 (`AccountView`)
|
||||
- **홈 대시보드 '이번 달 상환 예정' 카드 (유료)** — 현재일 기준 대출·카드할부 상환 예정 합계, **가계부에 상환 기록 있으면 제외**, 모두 기록 시 '상환 완료' 표시
|
||||
- **AI 교통 절약 팁 제외** — 버스·지하철·대중교통 분류는 정기권 최저비용 사용 중으로 tips에서 제외 (`AiEntryParser`)
|
||||
|
||||
**2026-07-05 추가 (웹 배포됨, v3 빌드 대기)**
|
||||
- **AI 편의 기능** — 자연어 입력·영수증 비전·분류/계좌 추천·재무 코멘트·절약 가이드 (상세는 아래 🤖 섹션)
|
||||
- **홈 대시보드 오늘 기록 넛지** — '안녕하세요 OO님' → 오늘 기록 상태로 대체(없으면 '지금 기록하기' CTA / 있으면 'N건'·연속 2일+ 'N일 연속 🔥')
|
||||
- **통계 화면 정리** — '예산 대비'+'수입 대비' 두 패널 → '이번 달 현황' 단일 패널 통합; 예상 지출은 **결정적 계산**(일회성 큰 지출 제외, 매 접속 동일값); AI 코멘트 로딩 인디케이터
|
||||
- **한 달 개근 보너스 포인트** — 현재 월 모든 날 기록 시 **랜덤 1~100P**(월 1회, 전월 소급 제외, 화면 안내 없음, `MONTH_COMPLETE_yyyyMM`로 중복 차단)
|
||||
- **iOS 안전영역·클립보드 수정** — 사이드바 상단 상태바 겹침 해결; 모달 열 때 클립보드 자동읽기 제거(iOS '붙여넣기' 토스트 방지). 음성 입력=iOS 키보드 받아쓰기(실기기 확인)
|
||||
|
||||
---
|
||||
|
||||
## 📌 남은 백로그 (출시 후 업데이트)
|
||||
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
|
||||
2. **관리자 신고글 목록 페이지**
|
||||
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
|
||||
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
|
||||
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
|
||||
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
|
||||
2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
|
||||
3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
|
||||
4. ✅ **포인트 상점** (2026-07-10 구현 완료)
|
||||
- **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
|
||||
- **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
|
||||
- FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
|
||||
- PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
|
||||
|
||||
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
|
||||
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
|
||||
- **AI 입력 보조** (편의성, 1순위)
|
||||
- ① **자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
|
||||
- ② **영수증 사진 → AI 구조화** — 멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
|
||||
- ③ **자동 분류 추천** — 가맹점/메모→분류 제안
|
||||
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
|
||||
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
|
||||
### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
|
||||
- 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
|
||||
- ✅ **① 자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움. 내역 화면 '✨ 빠른 입력' 바 진입점. (음성=iOS 키보드 받아쓰기, 실기기 확인)
|
||||
- ✅ **② 영수증 사진 → AI 구조화** — Claude Vision이 금액·상호·날짜·분류·계좌 추출(`/ocr/receipt-ai`), 실패 시 Google Vision 폴백.
|
||||
- ✅ **③ 분류·계좌 자동 추천** — 사용자 실제 분류·계좌 목록 중에서 선택(할루시네이션 방지 검증).
|
||||
- ✅ **AI 재무 코멘트 + 절약 가이드** — 통계 화면, **집계 수치만** 전송(거래 원문 미전송), 정중체. 예상 지출은 비결정성 때문에 **결정적 계산**(일회성 큰 지출 제외)으로 분리.
|
||||
- ✅ **개인정보처리방침·데이터안전 갱신** — 처리자 Anthropic(Claude) 명시, 전송 데이터 범위·학습 미사용 고지 (terms.js §2·§3, play-store-listing.md 데이터안전).
|
||||
- 원칙 준수: AI는 폼 prefill·참고 코멘트만, **저장은 사용자 확인 후**.
|
||||
|
||||
---
|
||||
|
||||
## 🔨 빌드 상태
|
||||
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
|
||||
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
|
||||
- ⚠️ 서버 `.env`에 `SQL_INIT_MODE=always` 추가 필요 (budget_onetime 테이블 자동 생성)
|
||||
- ⚠️ 가계부 내역 오류 미해결 — 서버 로그 + DevTools 확인 필요
|
||||
|
||||
- **v3** (versionCode 3): 위 'v2 이후 추가' + **AI 기능(자연어·영수증·분류/계좌·재무코멘트)** 전부 반영 — 빌드 후 비공개 트랙 업로드 (14일 카운트 리셋 안 됨)
|
||||
- ⚠️ 빌드 전 서버 `.env`에 `ANTHROPIC_API_KEY`(AI) 확인
|
||||
- ✅ 가계부 내역 오류 해결됨 (2026-07-05)
|
||||
- ✅ 1회성 예산(BudgetOnetime) 백엔드 배포 완료 (2026-07-05)
|
||||
---
|
||||
|
||||
## 다음 세션 재개 지점
|
||||
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`)
|
||||
2. **가계부 내역 오류 디버깅** — 서버 로그(`journalctl -u sb-backend`) + DevTools Network 탭
|
||||
3. v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게
|
||||
0. ⏳ **[진행중] iOS App Store 심사 결과 대기** — v1.0 제출 완료. 승인 시 출시(자동/수동), 거부 시 Resolution Center 대응(주로 데모 접근 안내)
|
||||
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
|
||||
2. **v5 AAB 비공개 트랙 업로드** — 이번 작업분(애플로그인·페이지개편·상환조회 등) 반영해 AAB 재빌드·업로드
|
||||
3. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환
|
||||
4. **수익화(출시 후)** — 구독(iOS StoreKit 미구현 → 결제코드+ASC상품+영수증검증 필요) / 광고(광고SDK+ATT+App Privacy 갱신). 둘 다 무료 출시 후 업데이트로
|
||||
5. (선택) **음성 전용 🎤 버튼** — `@capacitor-community/speech-recognition`
|
||||
6. **CI 러너 안정화** — Gitea act_runner 멈춤(재부팅 후 대기)로 배포 지연된 적 있음 → 자동재시작/헬스체크
|
||||
|
||||
@@ -57,13 +57,13 @@
|
||||
|
||||
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
|
||||
|
||||
문의: sbsb401824@gmail.com
|
||||
문의: dondwaeji.app@gmail.com
|
||||
```
|
||||
|
||||
### 기타 등록정보 필드
|
||||
- **앱 카테고리**: 금융 (Finance)
|
||||
- **태그**: 가계부, 예산, 자산관리
|
||||
- **연락처 이메일**: sbsb401824@gmail.com
|
||||
- **연락처 이메일**: dondwaeji.app@gmail.com
|
||||
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy
|
||||
- **그래픽 에셋**:
|
||||
- 앱 아이콘: brand/store/icon-512.png
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
### 개요 질문
|
||||
- 데이터를 수집하나요? → **예**
|
||||
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR은 처리 위탁이며 별도 공유 아님)
|
||||
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR·AI(Anthropic)는 대행 처리(처리 위탁)이며 Google Play 기준 '공유'에 해당하지 않음)
|
||||
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
|
||||
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
|
||||
|
||||
@@ -100,6 +100,15 @@
|
||||
|
||||
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
|
||||
|
||||
### AI 편의 기능의 데이터 처리 (처리 위탁, 공유 아님)
|
||||
> 아래는 개인정보처리방침 3항과 정합. 회원이 해당 기능을 쓸 때에만, 기능 수행 목적으로 Anthropic(Claude) API에 전송됨. Anthropic은 학습에 사용하지 않고, 서비스도 별도 저장하지 않음(폼 자동 채움용). API 키는 서버 보관.
|
||||
> - 자연어 한 줄 입력: 입력 텍스트 → 금액·분류 등 구조화
|
||||
> - 영수증 사진 구조화: 영수증 이미지 → 금액·상호·날짜·분류
|
||||
> - 분류·계좌 자동 추천: 분류·계좌 '이름' 목록
|
||||
> - 재무 코멘트: 이번 달 **집계 수치만**(개별 거래 원문 미전송)
|
||||
>
|
||||
> Play 데이터 안전에서 위 데이터 유형(금융 정보·사진)의 **공유 여부는 계속 '아니요'** (대행 처리는 공유 아님). 다만 방침에 처리자(Anthropic)를 명시했으므로 심사자 교차확인에도 일치함.
|
||||
|
||||
---
|
||||
|
||||
## 3. 콘텐츠 등급 설문 (IARC)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>돈돼지 가계부</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
327E84D03001463F00AB0467 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -62,6 +63,7 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
327E84D03001463F00AB0467 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -103,8 +105,9 @@
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -196,6 +199,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -203,8 +207,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -214,8 +220,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = G9PDM2C4L6;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -234,6 +242,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -253,6 +262,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -260,8 +270,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -271,8 +283,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = G9PDM2C4L6;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -284,6 +298,7 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
@@ -295,6 +310,7 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -305,7 +321,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -317,6 +333,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -326,7 +343,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
||||
+10
-3
@@ -230,7 +230,10 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
'bottom bottom';
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 100vh;
|
||||
/* 앱 셸: 뷰포트 높이에 고정하고 본문(layout-body)만 스크롤 → 상단 헤더·하단 내비 고정, 페이지 전체 스크롤 방지 */
|
||||
height: 100vh; /* 폴백 */
|
||||
height: 100dvh; /* 동적 뷰포트(주소창·안전영역 반영) */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-top {
|
||||
@@ -394,9 +397,13 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
.layout-body {
|
||||
grid-area: body;
|
||||
padding: 1.5rem 2rem;
|
||||
/* 하단 고정 내비(56px)+소프트키 인셋만큼 비워 콘텐츠가 가리지 않게 */
|
||||
/* 하단 고정 내비(56px + 안전영역) 뒤로 마지막 내용이 가려지지 않도록 항상 여백 확보 */
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0) + 1rem);
|
||||
overflow: auto;
|
||||
/* 그리드 1fr 트랙 안에서만 스크롤(콘텐츠로 인해 트랙이 늘어나 페이지 전체가 스크롤되지 않도록 min-height:0) */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* 페이지 가로 스크롤 방지 — 넓은 표/차트는 각자 컨테이너에서 스크롤 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.layout-bottom {
|
||||
|
||||
+38
-4
@@ -15,6 +15,10 @@ const realApi = {
|
||||
categoryStats({ type, year, month } = {}) {
|
||||
return http.get('/account/category-stats', { params: { type, year, month } })
|
||||
},
|
||||
// 통계 화면 집계 → AI 재무 코멘트(유료). 실패해도 화면을 막지 않도록 전역 배너 억제
|
||||
aiComment(payload) {
|
||||
return http.post('/account/ai-comment', payload, { timeout: 30000, suppressNetError: true })
|
||||
},
|
||||
netWorthTrend({ months, weeks, unit } = {}) {
|
||||
return http.get('/account/networth/trend', { params: { months, weeks, unit } })
|
||||
},
|
||||
@@ -28,8 +32,9 @@ const realApi = {
|
||||
return http.delete(`/account/entries/${id}`)
|
||||
},
|
||||
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
|
||||
parseText({ title, text } = {}) {
|
||||
return http.post('/account/entries/parse', { title, text })
|
||||
// categoryHints/walletHints: AI가 이 후보들 중에서 분류·결제수단을 고르도록 사용자 목록 전달
|
||||
parseText({ title, text, categoryHints, walletHints } = {}) {
|
||||
return http.post('/account/entries/parse', { title, text, categoryHints, walletHints })
|
||||
},
|
||||
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
|
||||
fxRate(from, to = 'KRW') {
|
||||
@@ -215,13 +220,42 @@ const realApi = {
|
||||
return http.post('/account/restore', payload)
|
||||
},
|
||||
|
||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
|
||||
// 가계부 데이터 전체 삭제 (내역·계좌·분류·태그·예산·고정지출·투자 전부)
|
||||
deleteAllData() {
|
||||
return http.delete('/account/all')
|
||||
},
|
||||
|
||||
// 포인트 상점 — 현황 조회 / 아이템 구매
|
||||
shopStatus() {
|
||||
return http.get('/account/shop/status')
|
||||
},
|
||||
buyShopItem(item) {
|
||||
return http.post('/account/shop/buy', { item })
|
||||
},
|
||||
|
||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
|
||||
// suppressNetError: runOcr 이 자체 formError 로 안내하므로 전역 배너 억제
|
||||
ocrReceipt(blob) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', blob, 'receipt.jpg')
|
||||
return http.post('/account/ocr/receipt', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000,
|
||||
suppressNetError: true,
|
||||
})
|
||||
},
|
||||
// 영수증 AI 구조화 (Claude Vision) — 금액·상호·날짜·분류·계좌를 한 번에. 사용자 후보 목록을 함께 전달
|
||||
// 비전 처리가 느릴 수 있어 타임아웃 상향, 실패 시 OCR 폴백이 있으므로 전역 배너 억제
|
||||
parseReceiptAi(blob, { categoryHints = [], walletHints = [] } = {}) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', blob, 'receipt.jpg')
|
||||
// 멀티파트 파트 수 제한(Tomcat 기본 10) 회피: 후보를 각각 한 파트에 줄바꿈으로 합쳐 전송
|
||||
if (categoryHints.length) fd.append('categoryHints', categoryHints.join('\n'))
|
||||
if (walletHints.length) fd.append('walletHints', walletHints.join('\n'))
|
||||
return http.post('/account/ocr/receipt-ai', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
suppressNetError: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -245,7 +279,7 @@ const DEMO_WRITES = new Set([
|
||||
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
|
||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData', 'buyShopItem',
|
||||
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||
'refreshPrices', 'refreshAllPrices',
|
||||
|
||||
+4
-3
@@ -47,9 +47,10 @@ export const authApi = {
|
||||
points() {
|
||||
return http.get('/auth/points')
|
||||
},
|
||||
// 포인트 적립/차감 내역 (최신순)
|
||||
pointHistory() {
|
||||
return http.get('/auth/point-history')
|
||||
// 포인트 적립/차감 내역 — year+month 지정 시 해당 월, 미지정 시 최근 100건
|
||||
pointHistory(year, month) {
|
||||
const params = year && month ? { year, month } : {}
|
||||
return http.get('/auth/point-history', { params })
|
||||
},
|
||||
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
|
||||
profile() {
|
||||
|
||||
+2
-1
@@ -34,8 +34,9 @@ http.interceptors.response.use(
|
||||
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
||||
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
||||
window.dispatchEvent(new CustomEvent('premium:required'))
|
||||
} else if (!error.response && error.code !== 'ERR_CANCELED') {
|
||||
} else if (!error.response && error.code !== 'ERR_CANCELED' && !error.config?.suppressNetError) {
|
||||
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
|
||||
// suppressNetError: 자체 폴백/에러표시가 있는 호출(예: 영수증 AI)은 전역 배너를 띄우지 않음.
|
||||
window.dispatchEvent(new CustomEvent('net:error'))
|
||||
}
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -19,7 +19,7 @@ const TITLES = {
|
||||
'demo-locked': '둘러보기',
|
||||
'account-entries': '가계부 내역',
|
||||
'account-stats': '통계',
|
||||
'account-recurrings': '고정 지출',
|
||||
'account-recurrings': '정기결제',
|
||||
'account-wallets': '계좌 관리',
|
||||
'account-categories': '분류 관리',
|
||||
'account-budget': '예산 설정',
|
||||
|
||||
@@ -26,6 +26,7 @@ const icons = {
|
||||
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
||||
repay: '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,13 +53,18 @@ const icons = {
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/recurrings" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
|
||||
<span>고정 지출</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
<span>정기결제</span>
|
||||
<!-- 무료도 2건까지 사용 가능 → 🔒 미표시 -->
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/wallets" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
||||
<span>계좌 관리</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/repayments" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.repay" />
|
||||
<span>대출·할부 상환</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/categories" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.categories" />
|
||||
<span>분류 관리</span>
|
||||
@@ -120,7 +126,8 @@ const icons = {
|
||||
.app-sidebar {
|
||||
background: var(--color-background-soft);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 0 0 1rem;
|
||||
/* iOS 상태바(노치) 영역만큼 위 여백 — 드로어 상단이 상태바에 가리지 않도록 */
|
||||
padding: env(safe-area-inset-top, 0) 0 1rem;
|
||||
}
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
// 계좌 선택기: 종류(대분류) 클릭 → 해당 종류 계좌(소분류) 펼침. CategoryPicker 형태.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BottomSheet from './BottomSheet.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
wallets: { type: Array, default: () => [] }, // { id, name, type, issuer }
|
||||
title: { type: String, default: '계좌 선택' },
|
||||
placeholder: { type: String, default: '계좌를 선택하세요' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
emptyText: { type: String, default: '선택할 계좌가 없습니다' },
|
||||
allLabel: { type: String, default: '' }, // 필터용 '전체' 옵션(설정 시 노출)
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const KINDS = [
|
||||
{ key: 'BANK', label: '은행계좌' },
|
||||
{ key: 'CASH', label: '현금' },
|
||||
{ key: 'CARD', label: '카드' },
|
||||
{ key: 'LOAN', label: '대출' },
|
||||
{ key: 'MINUS', label: '마이너스' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
const groups = computed(() =>
|
||||
KINDS.map((k) => ({ ...k, items: props.wallets.filter((w) => w.type === k.key) })).filter(
|
||||
(g) => g.items.length,
|
||||
),
|
||||
)
|
||||
const selected = computed(() => props.wallets.find((w) => w.id === props.modelValue) || null)
|
||||
const triggerLabel = computed(() => {
|
||||
if (selected.value) return selected.value.name
|
||||
if (props.allLabel && !props.modelValue) return props.allLabel
|
||||
return props.placeholder
|
||||
})
|
||||
const isEmptyState = computed(() => !selected.value && !(props.allLabel && !props.modelValue))
|
||||
|
||||
const open = ref(false)
|
||||
const expanded = ref('')
|
||||
const expandedGroup = computed(() => groups.value.find((g) => g.key === expanded.value) || null)
|
||||
watch(open, (o) => {
|
||||
if (o) expanded.value = selected.value?.type || (groups.value.length === 1 ? groups.value[0].key : '')
|
||||
})
|
||||
function toggle(k) {
|
||||
expanded.value = expanded.value === k ? '' : k
|
||||
}
|
||||
function pick(id) {
|
||||
emit('update:modelValue', id)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button" class="ap-trigger" :class="{ empty: isEmptyState }"
|
||||
:disabled="disabled" @click="open = true"
|
||||
>
|
||||
<span class="ap-label">{{ triggerLabel }}</span>
|
||||
<span class="ap-caret">▾</span>
|
||||
</button>
|
||||
|
||||
<BottomSheet v-model="open" :title="title">
|
||||
<div v-if="groups.length" class="ap-picker">
|
||||
<div class="ap-grid">
|
||||
<button
|
||||
v-if="allLabel"
|
||||
type="button" class="ap-chip all" :class="{ active: !modelValue }"
|
||||
@click="pick('')"
|
||||
>{{ allLabel }}</button>
|
||||
<button
|
||||
v-for="g in groups" :key="g.key"
|
||||
type="button" class="ap-chip major" :class="{ active: expanded === g.key }"
|
||||
@click="toggle(g.key)"
|
||||
>{{ g.label }}</button>
|
||||
</div>
|
||||
<div v-if="expandedGroup" class="ap-sub-grid">
|
||||
<button
|
||||
v-for="w in expandedGroup.items" :key="w.id"
|
||||
type="button" class="ap-chip sub" :class="{ active: w.id === modelValue }"
|
||||
@click="pick(w.id)"
|
||||
>{{ w.name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="ap-empty">{{ emptyText }}</p>
|
||||
</BottomSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ap-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ap-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ap-trigger.empty .ap-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ap-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ap-caret {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ap-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0 0.4rem;
|
||||
}
|
||||
.ap-sub-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
border-radius: 0 6px 6px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
|
||||
}
|
||||
.ap-chip {
|
||||
flex: 1 1 calc(25% - 0.35rem);
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ap-chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ap-empty {
|
||||
padding: 1.2rem 0.4rem;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
// 아래에서 올라오는 바텀시트(콘텐츠 높이). 선택 항목(계좌·분류 등)에 사용.
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, required: true },
|
||||
title: { type: String, default: '' },
|
||||
})
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="bs">
|
||||
<div v-if="modelValue" class="bs-wrap" @click.self="$emit('update:modelValue', false)">
|
||||
<div class="bs-panel" role="dialog" aria-modal="true">
|
||||
<div class="bs-grip" @click="$emit('update:modelValue', false)"></div>
|
||||
<div class="bs-head">
|
||||
<span class="bs-title">{{ title }}</span>
|
||||
<button type="button" class="bs-close" aria-label="닫기" @click="$emit('update:modelValue', false)">×</button>
|
||||
</div>
|
||||
<div class="bs-body"><slot /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bs-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.bs-panel {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
background: var(--color-background);
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 1.1rem calc(1rem + env(safe-area-inset-bottom, 0));
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.bs-grip {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-border);
|
||||
margin: 0.4rem auto 0.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0 0.6rem;
|
||||
}
|
||||
.bs-title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.bs-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bs-enter-active {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.bs-leave-active {
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.bs-enter-from,
|
||||
.bs-leave-to {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.bs-enter-active .bs-panel {
|
||||
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.bs-leave-active .bs-panel {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
.bs-enter-from .bs-panel,
|
||||
.bs-leave-to .bs-panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ const props = defineProps({
|
||||
disabled: { type: Boolean, default: false },
|
||||
admin: { type: Boolean, default: false },
|
||||
reorderable: { type: Boolean, default: false },
|
||||
hideAdd: { type: Boolean, default: false }, // 분류 추가(+대분류/+소분류) 버튼 숨김 (필터 등 선택 전용)
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'category-added', 'rename', 'delete', 'reorder'])
|
||||
|
||||
@@ -229,10 +230,10 @@ onBeforeUnmount(destroySortables)
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', s.name)"
|
||||
>{{ s.name }}</button>
|
||||
<button type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button>
|
||||
<button v-if="!hideAdd" type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button>
|
||||
<button v-if="!hideAdd" type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button>
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
@@ -502,6 +503,7 @@ onBeforeUnmount(destroySortables)
|
||||
/* ── 인라인 추가 입력 ──────────────────────────────────── */
|
||||
.cat-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
// select 대체 칩 선택기. 균일한 박스 그리드로 정렬(계좌 등 선택지가 적을 때 드롭다운 대신).
|
||||
// 사용: <ChipSelect v-model="form.walletId" :options="[{value,label}]" :disabled="..." />
|
||||
defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] }, // [{ value, label }]
|
||||
disabled: { type: Boolean, default: false },
|
||||
emptyText: { type: String, default: '선택할 항목이 없습니다' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
function pick(v, disabled) {
|
||||
if (disabled) return
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chip-select">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="String(o.value)"
|
||||
type="button"
|
||||
class="cs-chip"
|
||||
:class="{ active: modelValue === o.value }"
|
||||
:disabled="disabled"
|
||||
:title="o.label"
|
||||
@click="pick(o.value, disabled)"
|
||||
>
|
||||
{{ o.label }}
|
||||
</button>
|
||||
<span v-if="!options.length" class="cs-empty">{{ emptyText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 계좌 선택 영역: 배경 패널 안에 칩 3개씩 균일 정렬 */
|
||||
.chip-select {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
min-width: 0; /* 긴 계좌명이 그리드를 부모보다 넓게 밀어 가로 오버플로우 나는 것 방지 */
|
||||
box-sizing: border-box;
|
||||
background: rgba(127, 127, 127, 0.08);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.cs-chip {
|
||||
min-width: 0; /* 그리드 아이템 기본 min-width:auto(=글자너비) 해제 → 칸이 줄고 긴 이름은 …로 */
|
||||
padding: 0.5rem 0.5rem;
|
||||
border: 1px solid var(--color-border-hover);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
.cs-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cs-chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cs-empty {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.55;
|
||||
padding: 0.28rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,7 @@ const ICONS = {
|
||||
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
|
||||
save: ['M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z', 'M17 21v-8H7v8', 'M7 3v5h8'],
|
||||
lock: ['M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z', 'M8 11V7a4 4 0 0 1 8 0v4'],
|
||||
sparkle: ['M12 4l1.6 6.4L20 12l-6.4 1.6L12 20l-1.6-6.4L4 12l6.4-1.6z'],
|
||||
}
|
||||
|
||||
const paths = computed(() => ICONS[props.icon] || ICONS.plus)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
// 필드형 트리거를 누르면 바텀시트로 옵션을 보여주고 단일 선택. (계좌·카드 선택 등)
|
||||
import { computed, ref } from 'vue'
|
||||
import BottomSheet from './BottomSheet.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] }, // [{ value, label }]
|
||||
placeholder: { type: String, default: '선택하세요' },
|
||||
title: { type: String, default: '선택' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
emptyText: { type: String, default: '선택할 항목이 없습니다' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const open = ref(false)
|
||||
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) || null)
|
||||
function pick(o) {
|
||||
emit('update:modelValue', o.value)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button" class="ss-trigger" :class="{ empty: !selected }"
|
||||
:disabled="disabled" @click="open = true"
|
||||
>
|
||||
<span class="ss-label">{{ selected ? selected.label : placeholder }}</span>
|
||||
<span class="ss-caret">▾</span>
|
||||
</button>
|
||||
|
||||
<BottomSheet v-model="open" :title="title">
|
||||
<div v-if="options.length" class="ss-chips">
|
||||
<button
|
||||
v-for="o in options" :key="o.value"
|
||||
type="button" class="ss-chip" :class="{ active: o.value === modelValue }"
|
||||
@click="pick(o)"
|
||||
>{{ o.label }}</button>
|
||||
</div>
|
||||
<p v-else class="ss-empty">{{ emptyText }}</p>
|
||||
</BottomSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ss-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ss-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ss-trigger.empty .ss-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ss-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ss-caret {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* CategoryPicker(.acc-chip)와 동일한 4열 균일 박스 그리드 */
|
||||
.ss-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0 0.4rem;
|
||||
}
|
||||
.ss-chip {
|
||||
flex: 1 1 calc(25% - 0.35rem);
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ss-chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ss-empty {
|
||||
padding: 1.2rem 0.4rem;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
+11
-4
@@ -2,9 +2,9 @@
|
||||
// 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리)
|
||||
|
||||
export const SERVICE_NAME = '돈돼지 가계부'
|
||||
export const EFFECTIVE_DATE = '2026-06-28'
|
||||
export const EFFECTIVE_DATE = '2026-07-05'
|
||||
// 개인정보 보호 문의 — 운영자 연락처(필요 시 변경)
|
||||
export const CONTACT_EMAIL = 'sbsb401824@gmail.com'
|
||||
export const CONTACT_EMAIL = 'dondwaeji.app@gmail.com'
|
||||
|
||||
export const TERMS_OF_SERVICE = `제1조 (목적)
|
||||
이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
|
||||
@@ -63,15 +63,22 @@ export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자
|
||||
2. 수집·이용 목적
|
||||
- 회원 식별 및 로그인 등 계정 관리
|
||||
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
|
||||
- AI 기반 편의 기능 제공(자연어 한 줄 입력, 영수증 사진 구조화, 분류·계좌 자동 추천, 이번 달 집계 기반 재무 코멘트)
|
||||
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
|
||||
- (향후 도입 시) 무료 회원 대상 광고 제공
|
||||
- 서비스 운영, 문의 응대 및 부정 이용 방지
|
||||
|
||||
3. 처리위탁 및 제3자
|
||||
서비스는 기능 제공을 위해 아래 사업자의 서비스를 이용하며, 필요한 범위에서 정보가 처리될 수 있습니다.
|
||||
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR)
|
||||
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR — AI 인식의 보조·폴백)
|
||||
- Anthropic(Claude): AI 편의 기능 처리. 회원이 해당 기능을 사용할 때에 한해 아래 정보가 처리 목적 범위에서 전송됩니다.
|
||||
· 자연어 한 줄 입력: 회원이 입력한 텍스트(예: "어제 스타벅스 5천원")
|
||||
· 영수증 사진 구조화: 회원이 촬영·선택한 영수증 이미지
|
||||
· 분류·계좌 자동 추천: 회원의 분류·계좌 '이름' 목록
|
||||
· 재무 코멘트: 이번 달 집계 수치(수입·지출·예산·분류별 합계 등)로 한정하며, 개별 거래 원문은 전송하지 않습니다.
|
||||
Anthropic은 API로 전송된 데이터를 AI 모델 학습에 사용하지 않습니다. API 키는 서버에만 보관되며 앱에 노출되지 않습니다.
|
||||
- Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
|
||||
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
|
||||
영수증 인식·AI 편의 기능은 회원이 제공한 정보를 해당 기능 수행 목적으로만 처리하며, 결과는 폼 자동 채움·참고 코멘트 용도로 저장은 회원 확인 후 이루어집니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
|
||||
|
||||
4. 광고
|
||||
- 현재 서비스에는 광고가 표시되지 않습니다.
|
||||
|
||||
@@ -12,7 +12,7 @@ export function isNativeApple() {
|
||||
export async function nativeAppleCredential() {
|
||||
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
|
||||
const result = await SignInWithApple.authorize({
|
||||
clientId: 'kr.sblog.slimbudget',
|
||||
clientId: 'kr.sblog.slimbudget.app',
|
||||
redirectURI: 'https://app.sblog.kr/auth/apple/callback',
|
||||
scopes: 'email name',
|
||||
})
|
||||
|
||||
+44
-1
@@ -139,6 +139,30 @@ const router = createRouter({
|
||||
component: () => import('../views/account/AccountWalletView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/wallets/new',
|
||||
name: 'account-wallet-new',
|
||||
component: () => import('../views/account/WalletFormView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/wallets/:id(\\d+)/edit',
|
||||
name: 'account-wallet-edit',
|
||||
component: () => import('../views/account/WalletFormView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/wallets/:id(\\d+)',
|
||||
name: 'account-wallet-detail',
|
||||
component: () => import('../views/account/AccountWalletDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/repayments',
|
||||
name: 'account-repayments',
|
||||
component: () => import('../views/account/RepaymentScheduleView.vue'),
|
||||
meta: { requiresAuth: true, requiresPremium: true },
|
||||
},
|
||||
{
|
||||
path: '/account/categories',
|
||||
name: 'account-categories',
|
||||
@@ -155,7 +179,20 @@ const router = createRouter({
|
||||
path: '/account/recurrings',
|
||||
name: 'account-recurrings',
|
||||
component: () => import('../views/account/RecurringView.vue'),
|
||||
meta: { requiresAuth: true, requiresPremium: true },
|
||||
// 정기결제은 무료도 2건까지 체험(초과 시 업셀) — 유료 게이트 해제
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/recurrings/new',
|
||||
name: 'account-recurring-new',
|
||||
component: () => import('../views/account/RecurringFormView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/recurrings/:id(\\d+)/edit',
|
||||
name: 'account-recurring-edit',
|
||||
component: () => import('../views/account/RecurringFormView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
// 설정 (앱 하단 내비게이션 → 설정)
|
||||
{
|
||||
@@ -176,6 +213,12 @@ const router = createRouter({
|
||||
component: () => import('../views/settings/ProfileEditView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/shop',
|
||||
name: 'settings-shop',
|
||||
component: () => import('../views/settings/ShopView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액)
|
||||
// - 카드 할부: 무이자 가정(원금만, 이자 0)
|
||||
// - 대출: 상환방식별 상각 (원리금균등/원금균등/만기일시), 현재 남은 원금(balance) 기준
|
||||
|
||||
function addMonths(y, m, delta) {
|
||||
const d = new Date(y, m - 1 + delta, 1)
|
||||
return { y: d.getFullYear(), m: d.getMonth() + 1 }
|
||||
}
|
||||
function label(y, m) {
|
||||
return `${y}.${String(m).padStart(2, '0')}`
|
||||
}
|
||||
// (by,bm) - (ay,am) 개월 차
|
||||
function monthsBetween(ay, am, by, bm) {
|
||||
return (by - ay) * 12 + (bm - am)
|
||||
}
|
||||
|
||||
export function currentYm() {
|
||||
const d = new Date()
|
||||
return { y: d.getFullYear(), m: d.getMonth() + 1 }
|
||||
}
|
||||
|
||||
// 대출 남은 상환 스케줄. 반환: { rows:[{label,principal,interest,total,balance}], partial:boolean }
|
||||
export function loanSchedule(w, now = currentYm(), cap = 600) {
|
||||
const r = (Number(w.loanRate) || 0) / 100 / 12
|
||||
let bal = Math.abs(Number(w.balance) || 0) // 현재 남은 원금
|
||||
const method = w.loanMethod || 'EQUAL_PAYMENT'
|
||||
if (bal <= 0) return { rows: [], partial: false }
|
||||
|
||||
// 경과·남은 개월수
|
||||
let elapsed = 0
|
||||
if (w.loanStart) {
|
||||
const [sy, sm] = String(w.loanStart).slice(0, 7).split('-').map(Number)
|
||||
if (sy && sm) elapsed = Math.max(0, monthsBetween(sy, sm, now.y, now.m))
|
||||
}
|
||||
const nRem = w.loanMonths ? Math.max(1, Number(w.loanMonths) - elapsed) : null
|
||||
|
||||
const rows = []
|
||||
let { y, m } = now
|
||||
|
||||
if (method === 'EQUAL_PRINCIPAL') {
|
||||
// 원금균등: 매월 원금 고정 = 최초원금 / 총개월 (없으면 잔액/남은개월)
|
||||
const principalPer =
|
||||
w.loanAmount && w.loanMonths
|
||||
? Math.round(Number(w.loanAmount) / Number(w.loanMonths))
|
||||
: nRem
|
||||
? Math.round(bal / nRem)
|
||||
: bal
|
||||
while (bal > 0 && rows.length < cap) {
|
||||
const interest = Math.round(bal * r)
|
||||
const principal = Math.min(principalPer, bal)
|
||||
bal = Math.max(0, bal - principal)
|
||||
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
|
||||
;({ y, m } = addMonths(y, m, 1))
|
||||
}
|
||||
return { rows, partial: false }
|
||||
}
|
||||
|
||||
if (method === 'BULLET') {
|
||||
// 만기일시: 매월 이자만, 마지막 달에 원금 전액
|
||||
const n = nRem || 1
|
||||
for (let i = 0; i < n && rows.length < cap; i++) {
|
||||
const interest = Math.round(bal * r)
|
||||
const isLast = i === n - 1
|
||||
const principal = isLast ? bal : 0
|
||||
const nb = isLast ? 0 : bal
|
||||
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: nb })
|
||||
bal = nb
|
||||
;({ y, m } = addMonths(y, m, 1))
|
||||
}
|
||||
return { rows, partial: !nRem }
|
||||
}
|
||||
|
||||
// EQUAL_PAYMENT 원리금균등
|
||||
// (0) 월 상환금(고정)이 입력돼 있으면 그대로 사용 → 이자=잔액×이율, 원금=상환금-이자 로 은행과 정확히 일치
|
||||
const fixed = Number(w.loanPayment) || 0
|
||||
if (fixed > 0) {
|
||||
while (bal > 0 && rows.length < cap) {
|
||||
const interest = Math.round(bal * r)
|
||||
if (fixed <= interest) {
|
||||
// 상환금이 이자에 못 미침(데이터 이상) → 더 진행 못함
|
||||
rows.push({ label: label(y, m), principal: 0, interest, total: fixed, balance: bal })
|
||||
break
|
||||
}
|
||||
// 마지막 회차: 남은 잔액 한 번에 정산(은행은 잔여 반올림을 마지막 납입에 합산 → 추가 회차 안 생김)
|
||||
if (bal <= fixed) {
|
||||
rows.push({ label: label(y, m), principal: bal, interest, total: bal + interest, balance: 0 })
|
||||
break
|
||||
}
|
||||
const principal = fixed - interest
|
||||
bal = bal - principal
|
||||
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
|
||||
;({ y, m } = addMonths(y, m, 1))
|
||||
}
|
||||
return { rows, partial: false }
|
||||
}
|
||||
|
||||
// (1) 남은 개월수 n 산출: 최초 상환액으로 역산(loanStart 없어도 정확) → 안되면 loanStart 경과
|
||||
let n = null
|
||||
if (w.loanAmount && w.loanMonths && r > 0) {
|
||||
const origPay = (Number(w.loanAmount) * r) / (1 - Math.pow(1 + r, -Number(w.loanMonths)))
|
||||
// bal = origPay·(1-(1+r)^-n)/r → n = -ln(1 - bal·r/origPay) / ln(1+r)
|
||||
const ratio = 1 - (bal * r) / origPay
|
||||
if (ratio > 0 && ratio < 1) {
|
||||
n = Math.min(Number(w.loanMonths), Math.max(1, Math.ceil(-Math.log(ratio) / Math.log(1 + r) - 1e-9)))
|
||||
}
|
||||
}
|
||||
if (n == null) n = nRem
|
||||
if (n == null) {
|
||||
// 개월·원금 정보 부족 → 이번 달 이자만 안내(원금 분리 불가)
|
||||
const interest = Math.round(bal * r)
|
||||
return {
|
||||
rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }],
|
||||
partial: true,
|
||||
}
|
||||
}
|
||||
// (2) 스케줄 상환액은 '현재 잔액'을 n개월에 상각 → 원금 항상 양수, 잔액이 0으로 수렴
|
||||
// (최초원금 기준을 쓰면 잔액>원금 등 데이터 불일치 시 원금이 음수가 되어 잔액이 증가함)
|
||||
const payment = r > 0 ? Math.round((bal * r) / (1 - Math.pow(1 + r, -n))) : Math.round(bal / n)
|
||||
for (let i = 0; i < n && bal > 0 && rows.length < cap; i++) {
|
||||
const interest = Math.round(bal * r)
|
||||
let principal = Math.max(0, payment - interest)
|
||||
if (i === n - 1 || principal > bal) principal = bal // 마지막 회차 잔액 정산
|
||||
bal = Math.max(0, bal - principal)
|
||||
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
|
||||
;({ y, m } = addMonths(y, m, 1))
|
||||
}
|
||||
return { rows, partial: false }
|
||||
}
|
||||
|
||||
// 카드 할부(무이자) 남은 스케줄. entry: {amount, installmentMonths, entryDate}
|
||||
// 반환: { rows:[...], monthly } 또는 null(진행중 할부 아님)
|
||||
export function cardInstallmentSchedule(entry, now = currentYm()) {
|
||||
const N = Number(entry.installmentMonths) || 0
|
||||
const amount = Math.abs(Number(entry.amount) || 0)
|
||||
const date = entry.entryDate ? String(entry.entryDate).slice(0, 7) : ''
|
||||
if (N < 2 || amount <= 0 || !date) return null
|
||||
const [py, pm] = date.split('-').map(Number)
|
||||
if (!py || !pm) return null
|
||||
|
||||
const monthly = Math.round(amount / N)
|
||||
// 청구 회차: 구매 다음 달부터 N개월 (k = 1..N). 마지막 회차는 반올림 오차 정산.
|
||||
const all = []
|
||||
for (let k = 1; k <= N; k++) {
|
||||
const { y, m } = addMonths(py, pm, k)
|
||||
const principal = k === N ? amount - monthly * (N - 1) : monthly
|
||||
all.push({ y, m, principal })
|
||||
}
|
||||
const remaining = all.filter((x) => monthsBetween(now.y, now.m, x.y, x.m) >= 0)
|
||||
if (!remaining.length) return null
|
||||
|
||||
let bal = remaining.reduce((s, x) => s + x.principal, 0)
|
||||
const rows = remaining.map((x) => {
|
||||
bal -= x.principal
|
||||
return { label: label(x.y, x.m), principal: x.principal, interest: 0, total: x.principal, balance: Math.max(0, bal) }
|
||||
})
|
||||
return { rows, monthly }
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function goLogin() {
|
||||
<div class="locked">
|
||||
<div class="lock-big">🔒</div>
|
||||
<h2>{{ label }}는 로그인 후 이용할 수 있어요</h2>
|
||||
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 볼 수 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 수 있어요.</p>
|
||||
<p class="desc">둘러보기에서는 가계부 내역 · 정기결제 · 계좌 관리 · 분류 관리만 미리 볼 수 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 수 있어요.</p>
|
||||
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+129
-3
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { ID_LOGIN_ENABLED } from '@/config/features'
|
||||
import { enterDemo } from '@/demo'
|
||||
@@ -27,6 +28,8 @@ const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
|
||||
const budgetTotal = ref(0)
|
||||
const spentTotal = ref(0)
|
||||
const entries = ref([])
|
||||
// 이번 달 상환 예정(대출·카드할부) — 현재일 기준, 가계부에 상환 기록 있으면 제외
|
||||
const repayDue = ref({ total: 0, count: 0, hadAny: false })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
@@ -80,6 +83,19 @@ const daysInMonth = computed(() => new Date(year, month, 0).getDate())
|
||||
const firstWeekday = computed(() => new Date(year, month - 1, 1).getDay()) // 0=일
|
||||
const todayDate = now.getFullYear() === year && now.getMonth() + 1 === month ? now.getDate() : 0
|
||||
|
||||
// ===== 오늘 기록 넛지(습관 유도) =====
|
||||
const todayCount = computed(() => dayMap.value[todayDate]?.items?.length || 0)
|
||||
// 오늘부터 거슬러 연속으로 기록된 일수(이번 달 데이터 기준)
|
||||
const streak = computed(() => {
|
||||
if (!todayDate) return 0
|
||||
let s = 0
|
||||
for (let d = todayDate; d >= 1; d--) {
|
||||
if ((dayMap.value[d]?.items?.length || 0) > 0) s++
|
||||
else break
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const cells = []
|
||||
for (let i = 0; i < firstWeekday.value; i++) cells.push(null)
|
||||
@@ -120,7 +136,7 @@ const shortcuts = [
|
||||
{ to: '/account/stats', icon: '📊', label: '통계', desc: '차트로 보기' },
|
||||
{ to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' },
|
||||
{ to: '/account/budget', icon: '🎯', label: '예산 설정', desc: '예산 대비 지출' },
|
||||
{ to: '/account/recurrings', icon: '🔁', label: '고정 지출', desc: '매월·매년 반복' },
|
||||
{ to: '/account/recurrings', icon: '🔁', label: '정기결제', desc: '매월·매년 반복' },
|
||||
{ to: '/account/categories', icon: '🗂️', label: '분류 관리', desc: '카테고리' },
|
||||
]
|
||||
|
||||
@@ -130,17 +146,20 @@ async function load() {
|
||||
error.value = null
|
||||
try {
|
||||
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
||||
const [sum, nw, status, list] = await Promise.all([
|
||||
const [sum, nw, status, list, ws] = await Promise.all([
|
||||
accountApi.summary({ year, month }),
|
||||
accountApi.netWorth(),
|
||||
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
|
||||
accountApi.list({ year, month }),
|
||||
auth.isPremium ? accountApi.wallets() : Promise.resolve([]),
|
||||
])
|
||||
summary.value = sum
|
||||
networth.value = nw
|
||||
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
|
||||
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
|
||||
entries.value = list || []
|
||||
// 상환 예정은 유료 전용 — 무료 회원은 계산·조회 생략
|
||||
if (auth.isPremium) await computeRepayDue(ws || [], list || [])
|
||||
// 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외)
|
||||
if (reminders.isNative()) {
|
||||
const t = new Date()
|
||||
@@ -154,6 +173,54 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// 이번 달 상환 예정 계산: 대출 월 상환금 + 카드 할부(이번 달 회차).
|
||||
// '가계부에 상환 기록(REPAYMENT) 있으면 제외' — 해당 계좌는 이번 달 이미 납입한 것으로 봄.
|
||||
async function computeRepayDue(ws, monthEntries) {
|
||||
const nw = currentYm()
|
||||
const nowLabel = `${nw.y}.${String(nw.m).padStart(2, '0')}`
|
||||
const paid = new Set(
|
||||
(monthEntries || [])
|
||||
.filter((e) => e.type === 'REPAYMENT' && e.toWalletId)
|
||||
.map((e) => e.toWalletId),
|
||||
)
|
||||
let total = 0
|
||||
let count = 0
|
||||
let hadAny = false
|
||||
|
||||
// 대출: 이번 달 상환액(원금+이자)
|
||||
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
|
||||
const { rows } = loanSchedule(w, nw)
|
||||
const row = rows.find((r) => r.label === nowLabel)
|
||||
if (!row || !row.total) continue
|
||||
hadAny = true
|
||||
if (paid.has(w.id)) continue
|
||||
total += row.total
|
||||
count += 1
|
||||
}
|
||||
|
||||
// 카드 할부: 이번 달 청구 회차 합(무이자). 카드에 이번 달 상환 기록 있으면 제외.
|
||||
const cards = ws.filter((x) => x.type === 'CARD')
|
||||
if (cards.length) {
|
||||
const lists = await Promise.all(cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])))
|
||||
cards.forEach((c, i) => {
|
||||
let cardSum = 0
|
||||
for (const e of (lists[i] || []).filter((e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2)) {
|
||||
const sch = cardInstallmentSchedule(e, nw)
|
||||
if (!sch) continue
|
||||
const row = sch.rows.find((r) => r.label === nowLabel)
|
||||
if (row) cardSum += row.total
|
||||
}
|
||||
if (cardSum <= 0) return
|
||||
hadAny = true
|
||||
if (paid.has(c.id)) return
|
||||
total += cardSum
|
||||
count += 1
|
||||
})
|
||||
}
|
||||
|
||||
repayDue.value = { total, count, hadAny }
|
||||
}
|
||||
|
||||
// 로그인/로그아웃 전환 시 갱신
|
||||
watch(() => auth.isAuthenticated, (v) => {
|
||||
if (v) load()
|
||||
@@ -163,6 +230,7 @@ watch(() => auth.isAuthenticated, (v) => {
|
||||
budgetTotal.value = 0
|
||||
spentTotal.value = 0
|
||||
entries.value = []
|
||||
repayDue.value = { total: 0, count: 0, hadAny: false }
|
||||
activeDay.value = 0
|
||||
}
|
||||
})
|
||||
@@ -175,8 +243,18 @@ onMounted(load)
|
||||
<!-- ===== 로그인: 요약 대시보드 ===== -->
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<header class="greet">
|
||||
<h1>안녕하세요, {{ displayName }}님 👋</h1>
|
||||
<template v-if="loading">
|
||||
<h1>{{ monthLabel }} 가계부</h1>
|
||||
</template>
|
||||
<template v-else-if="todayCount > 0">
|
||||
<h1 v-if="streak >= 2">{{ streak }}일 연속 기록 중이에요 🔥</h1>
|
||||
<h1 v-else>오늘 {{ todayCount }}건 기록했어요 ✏️</h1>
|
||||
<p class="today">{{ monthLabel }} 가계부 요약</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1>{{ displayName }}님, 오늘은 아직 기록이 없어요</h1>
|
||||
<RouterLink to="/account/entries" class="greet-cta">+ 지금 기록하기</RouterLink>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
@@ -251,6 +329,22 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이번 달 상환 예정 (대출·카드할부, 현재일 기준 · 상환 기록 있으면 제외) -->
|
||||
<div v-if="auth.isPremium && repayDue.hadAny" class="card repay-card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">이번 달 상환 예정</span>
|
||||
<RouterLink to="/account/repayments" class="more">상환 →</RouterLink>
|
||||
</div>
|
||||
<div v-if="repayDue.count > 0" class="repay-body">
|
||||
<span class="repay-amt expense">{{ won(repayDue.total) }}</span>
|
||||
<span class="repay-sub">아직 기록되지 않은 대출·할부 {{ repayDue.count }}건</span>
|
||||
</div>
|
||||
<div v-else class="repay-body done">
|
||||
<span class="repay-amt">이번 달 상환 완료 ✅</span>
|
||||
<span class="repay-sub">예정된 대출·할부를 모두 기록했어요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예산 대비 지출 (유료 전용 — 무료 회원은 업그레이드 안내) -->
|
||||
<div class="card budget-card">
|
||||
<div class="card-head">
|
||||
@@ -385,6 +479,17 @@ onMounted(load)
|
||||
opacity: 0.65;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.greet-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* 신규 유저 시작하기 안내 */
|
||||
.getting-started {
|
||||
position: relative;
|
||||
@@ -529,6 +634,27 @@ onMounted(load)
|
||||
}
|
||||
|
||||
/* 예산 대비 지출 카드 */
|
||||
.repay-card {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.repay-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.repay-amt {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.repay-body.done .repay-amt {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.repay-sub {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.budget-card {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ async function buy(product) {
|
||||
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
|
||||
const freeFeatures = [
|
||||
'가계부 내역 기록 · 조회',
|
||||
'계좌 관리 (개수 제한 없음)',
|
||||
'계좌 관리 (종류별 2개)',
|
||||
'정기결제 (2개까지 자동 반영)',
|
||||
'자주 쓰는 내역',
|
||||
'월별 수입/지출 요약',
|
||||
'기본 분류',
|
||||
@@ -67,10 +68,11 @@ const freeFeatures = [
|
||||
const premiumFeatures = [
|
||||
'전체 통계 (분류별·추이·자산)',
|
||||
'예산 설정',
|
||||
'고정 지출 자동 반영',
|
||||
'계좌 · 정기결제 무제한',
|
||||
'영수증 OCR 인식',
|
||||
'카드 알림 자동 인식',
|
||||
'투자 · 자산 관리',
|
||||
'대출 · 할부 상환표 (원금 · 이자 분리)',
|
||||
'태그 · 소분류 무제한',
|
||||
'데이터 백업 / 복구 (엑셀)',
|
||||
'광고 제거',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
@@ -24,6 +27,15 @@ const catType = ref('EXPENSE') // 분류별 파이차트 구분
|
||||
const catData = ref([]) // [{category, total}]
|
||||
const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
|
||||
const trendData = ref([]) // [{month, netWorth}]
|
||||
const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰)
|
||||
const aiTips = ref([]) // AI 절약 가이드(실행 제안)
|
||||
const forecast = ref(null) // 월말 예상 지출(결정적 계산, 일회성 큰 지출 제외)
|
||||
const forecastNote = ref('') // 예상 지출 근거 문장
|
||||
const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
|
||||
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
|
||||
const forecastLoading = ref(false)
|
||||
const aiLoading = ref(false)
|
||||
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
@@ -62,16 +74,66 @@ const expenseDelta = computed(() => {
|
||||
return { prev, diff, pct }
|
||||
})
|
||||
|
||||
/* ===== 이번달 예상 지출 (실제 지출 추세로 월말 추정, run-rate) =====
|
||||
경과일까지 실제 지출을 일평균으로 환산해 그 달 총일수로 추정. 이번 달에만 표시. */
|
||||
const expenseForecast = computed(() => {
|
||||
/* ===== 월말 예상 지출 (결정적 계산 — 매 접속 동일값) =====
|
||||
일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은
|
||||
일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */
|
||||
function robustDailyRate(days) {
|
||||
if (!days.length) return { rate: 0, dropped: 0, droppedCount: 0 }
|
||||
const sorted = [...days].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
// 비정기 큰 지출(대출상환·가전·여행 등)은 남은 날에 반복되지 않으므로,
|
||||
// 중앙값의 3배(또는 10만원)를 넘는 '모든' 날을 하루평균 산정에서 제외(가장 큰 1건만이 아님).
|
||||
const threshold = Math.max(median * 3, 100000)
|
||||
const canTrim = sorted.length >= 4
|
||||
const kept = []
|
||||
let dropped = 0
|
||||
let droppedCount = 0
|
||||
for (const d of sorted) {
|
||||
if (canTrim && d > threshold) {
|
||||
dropped += d
|
||||
droppedCount++
|
||||
} else {
|
||||
kept.push(d)
|
||||
}
|
||||
}
|
||||
const rate = kept.length ? kept.reduce((a, b) => a + b, 0) / kept.length : 0
|
||||
return { rate, dropped, droppedCount }
|
||||
}
|
||||
async function loadForecast() {
|
||||
forecast.value = null
|
||||
forecastNote.value = ''
|
||||
forecastHint.value = ''
|
||||
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
|
||||
if (!isCurrent) return null // 지난/미래 달은 추정 불필요(이미 확정 또는 데이터 없음)
|
||||
if (!isCurrent) return // 과거/미래 달은 추정 불필요
|
||||
const totalDays = daysInMonth(year.value, month.value)
|
||||
const elapsed = now.getDate()
|
||||
const projected = elapsed > 0 ? Math.round((monthExpense.value / elapsed) * totalDays) : monthExpense.value
|
||||
return { projected, elapsed, totalDays }
|
||||
})
|
||||
if (elapsed < 1) return
|
||||
// 초반엔 표본이 적어 추정이 크게 튀므로 일정 경과일 전에는 값 대신 안내만 표시
|
||||
if (elapsed < FORECAST_MIN_DAYS) {
|
||||
forecastHint.value = `${FORECAST_MIN_DAYS}일부터 표시돼요`
|
||||
return
|
||||
}
|
||||
forecastLoading.value = true
|
||||
try {
|
||||
const daily = await accountApi.budgetPeriod({ unit: 'DAY', year: year.value, month: month.value })
|
||||
const map = {}
|
||||
;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0))
|
||||
const days = []
|
||||
for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0)
|
||||
const { rate, dropped, droppedCount } = robustDailyRate(days)
|
||||
const remaining = totalDays - elapsed
|
||||
forecast.value = Math.round(monthExpense.value + rate * remaining)
|
||||
forecastNote.value =
|
||||
droppedCount > 0
|
||||
? `대출상환 등 비정기 큰 지출 ${droppedCount}건(합계 ${won(dropped)})은 남은 ${remaining}일 추정에서 제외`
|
||||
: `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정`
|
||||
} catch {
|
||||
forecast.value = null
|
||||
} finally {
|
||||
forecastLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
|
||||
const bars = computed(() => {
|
||||
@@ -264,6 +326,8 @@ async function load() {
|
||||
monthIncome.value = summary.totalIncome
|
||||
monthExpense.value = summary.totalExpense
|
||||
prevMonthExpense.value = prevSummary.totalExpense
|
||||
loadForecast() // 결정적 예상 지출(비동기)
|
||||
loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
@@ -271,6 +335,36 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
|
||||
async function loadAiComment() {
|
||||
aiLoading.value = true
|
||||
aiNoCredit.value = false
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
try {
|
||||
const res = await accountApi.aiComment({
|
||||
year: year.value,
|
||||
month: month.value,
|
||||
income: monthIncome.value,
|
||||
expense: monthExpense.value,
|
||||
prevExpense: prevMonthExpense.value,
|
||||
budget: budgetTotal.value,
|
||||
spent: spentTotal.value,
|
||||
categories: (catData.value || []).slice(0, 8).map((c) => ({ category: c.category, total: c.total })),
|
||||
})
|
||||
aiBullets.value = res?.bullets || []
|
||||
aiTips.value = res?.tips || []
|
||||
} catch (e) {
|
||||
if (e.response?.status === 402) {
|
||||
aiNoCredit.value = true
|
||||
}
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setUnit(u) {
|
||||
unit.value = u
|
||||
loadBar()
|
||||
@@ -335,10 +429,38 @@ onMounted(async () => {
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 -->
|
||||
<div class="top-grid">
|
||||
<div class="panel donut-panel">
|
||||
<h2>예산 대비 지출</h2>
|
||||
<!-- 무료 회원 AI 크레딧 소진 안내 -->
|
||||
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
|
||||
<div class="ai-comment-head">✨ AI 코멘트</div>
|
||||
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 수 있어요.</p>
|
||||
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 ›</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 재무 코멘트 + 절약 가이드: 이번 달 집계 해석 -->
|
||||
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
|
||||
<div class="ai-comment-head">✨ 이번 달 AI 코멘트</div>
|
||||
<div v-if="aiLoading" class="ai-loading">
|
||||
<span class="ai-loading-text">집계를 분석하고 있어요…</span>
|
||||
<div class="ai-loading-bar"><span></span></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ul v-if="aiBullets.length" class="ai-comment-list">
|
||||
<li v-for="(b, i) in aiBullets" :key="i">{{ b }}</li>
|
||||
</ul>
|
||||
<div v-if="aiTips.length" class="ai-tips">
|
||||
<div class="ai-tips-head">💡 절약 가이드</div>
|
||||
<ul class="ai-comment-list">
|
||||
<li v-for="(t, i) in aiTips" :key="i">{{ t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 이번 달 현황 (예산 도넛 + 수입/지출 통합) -->
|
||||
<div class="panel month-overview">
|
||||
<h2>이번 달 현황</h2>
|
||||
<div class="mo-grid">
|
||||
<div class="mo-budget">
|
||||
<div v-if="budgetTotal > 0" class="donut-wrap">
|
||||
<svg viewBox="0 0 120 120" class="donut">
|
||||
<circle cx="60" cy="60" :r="R" fill="none" stroke="var(--color-background-mute)" stroke-width="14" />
|
||||
@@ -360,8 +482,6 @@ onMounted(async () => {
|
||||
<p v-else class="empty">설정된 예산이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>수입 대비 지출</h2>
|
||||
<div class="ie">
|
||||
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
|
||||
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
|
||||
@@ -371,17 +491,23 @@ onMounted(async () => {
|
||||
{{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
|
||||
</b>
|
||||
</div>
|
||||
<div v-if="expenseForecast" class="ie-row forecast">
|
||||
<div v-if="forecastHint" class="ie-row forecast">
|
||||
<span>예상 지출 <small>{{ forecastHint }}</small></span>
|
||||
<b class="pending">—</b>
|
||||
</div>
|
||||
<div v-else-if="forecastLoading && !forecast" class="ie-row forecast">
|
||||
<span>예상 지출 <small>계산 중…</small></span>
|
||||
<b class="pending">···</b>
|
||||
</div>
|
||||
<div v-else-if="forecast" class="ie-row forecast">
|
||||
<span>예상 지출 <small>월말 추정</small></span>
|
||||
<b class="expense">{{ won(expenseForecast.projected) }}</b>
|
||||
<b class="expense">{{ won(forecast) }}</b>
|
||||
</div>
|
||||
<div class="ie-bar">
|
||||
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
|
||||
</div>
|
||||
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
|
||||
<p v-if="expenseForecast" class="forecast-note">
|
||||
최근 추세 기준({{ expenseForecast.elapsed }}/{{ expenseForecast.totalDays }}일 · 현재 {{ won(monthExpense) }})<template v-if="budgetTotal > 0">, 예산 대비 {{ Math.round((expenseForecast.projected / budgetTotal) * 100) }}%</template>
|
||||
</p>
|
||||
<p v-if="forecastNote" class="forecast-note">{{ forecastNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,6 +677,89 @@ h2 {
|
||||
padding: 1rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* AI 재무 코멘트 카드 */
|
||||
.ai-comment {
|
||||
border: 1px solid hsla(160, 100%, 37%, 0.45);
|
||||
border-radius: 12px;
|
||||
background: hsla(160, 100%, 37%, 0.07);
|
||||
padding: 0.9rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ai-comment-head {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: hsla(160, 100%, 32%, 1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ai-loading-text {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.ai-loading-bar {
|
||||
height: 4px;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: hsla(160, 100%, 37%, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-loading-bar > span {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: hsla(160, 100%, 37%, 0.85);
|
||||
animation: ai-indeterminate 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-indeterminate {
|
||||
0% { transform: translateX(-110%); }
|
||||
100% { transform: translateX(360%); }
|
||||
}
|
||||
.ie-row .pending {
|
||||
opacity: 0.4;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.ai-comment-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.ai-comment-list li {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ai-tips {
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed hsla(160, 100%, 37%, 0.35);
|
||||
}
|
||||
.ai-tips-head {
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
color: #b8860b;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ai-no-credit {
|
||||
border-color: hsla(220, 80%, 60%, 0.4);
|
||||
}
|
||||
.ai-credit-msg {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.ai-shop-btn {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -564,6 +773,26 @@ h2 {
|
||||
.top-grid .panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* 이번 달 현황: 예산 도넛 + 수입/지출을 한 패널에 좌우로 */
|
||||
.month-overview .mo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.mo-budget {
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.month-overview .mo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.mo-budget {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.9rem;
|
||||
}
|
||||
}
|
||||
.donut-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, nextTick, watch } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
@@ -11,6 +11,11 @@ const tags = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const newName = ref('')
|
||||
const reorderMode = ref(false)
|
||||
|
||||
function toggleReorder() {
|
||||
reorderMode.value = !reorderMode.value
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -88,20 +93,32 @@ async function removeTag(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
watch(reorderMode, async (on) => {
|
||||
if (on) {
|
||||
await nextTick()
|
||||
initSortable()
|
||||
} else {
|
||||
if (sortable) { sortable.destroy(); sortable = null }
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => sortable?.destroy())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tag-admin">
|
||||
<div class="top-row">
|
||||
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
||||
<button v-if="tags.length" type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
|
||||
{{ reorderMode ? '완료' : '순서변경' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="reorderMode" class="hint sm">⠿ 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b>.</p>
|
||||
|
||||
<form class="new-tag" @submit.prevent="addTag">
|
||||
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 고정지출)" />
|
||||
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 정기결제)" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
|
||||
</form>
|
||||
|
||||
@@ -110,10 +127,10 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
|
||||
<ul v-show="!loading && tags.length" ref="listEl" class="tag-list">
|
||||
<li v-for="t in tags" :key="t.id" :data-id="t.id" class="tag-row">
|
||||
<span class="drag-handle" title="드래그하여 순서 변경">≡</span>
|
||||
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" />
|
||||
<IconBtn icon="check" title="저장" @click="saveTag(t)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
|
||||
<span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경">≡</span>
|
||||
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" :disabled="reorderMode" />
|
||||
<IconBtn v-if="!reorderMode" icon="check" title="저장" @click="saveTag(t)" />
|
||||
<IconBtn v-if="!reorderMode" icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!loading && !tags.length" class="msg">등록된 태그가 없습니다.</p>
|
||||
@@ -133,11 +150,37 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.hint.sm {
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.text-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.text-btn.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
+464
-115
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -7,6 +8,9 @@ import { useDialog } from '@/composables/dialog'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
import SheetSelect from '@/components/ui/SheetSelect.vue'
|
||||
import AccountPicker from '@/components/ui/AccountPicker.vue'
|
||||
import BottomSheet from '@/components/ui/BottomSheet.vue'
|
||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||
import { cardNotif } from '@/native/cardNotif'
|
||||
import { CARD_NOTIF_ENABLED } from '@/config/features'
|
||||
@@ -82,10 +86,30 @@ function resetFilters() {
|
||||
load()
|
||||
}
|
||||
|
||||
// 추가/수정 모달
|
||||
// 추가/수정 폼 — 라우트(?entry) 바인딩으로 뒤로가기 시 닫힘(페이지 전환처럼 동작)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formOpen = ref(false)
|
||||
// 폼 열기: 상태 표시 + 히스토리에 ?entry 추가(브라우저 뒤로가기로 닫기)
|
||||
function openForm() {
|
||||
formOpen.value = true
|
||||
if (route.query.entry == null) router.push({ query: { ...route.query, entry: '1' } })
|
||||
}
|
||||
// 폼 닫기: 상태 내림 + URL 정리(뒤로가기)
|
||||
function closeForm() {
|
||||
formOpen.value = false
|
||||
if (route.query.entry != null) router.back()
|
||||
}
|
||||
// 브라우저 뒤로가기 등으로 ?entry 가 사라지면 폼 닫기
|
||||
watch(() => route.query.entry, (v) => { if (v == null) formOpen.value = false })
|
||||
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: '', currency: 'KRW', foreignAmount: null, rate: null })
|
||||
// 분류 선택 바텀시트 — 최종 분류가 정해지면 자동 닫힘
|
||||
const catSheet = ref(false)
|
||||
watch(() => form.category, (v) => { if (v) catSheet.value = false })
|
||||
// 검색 필터 분류 바텀시트
|
||||
const catFilterSheet = ref(false)
|
||||
watch(() => filters.category, (v) => { if (v) catFilterSheet.value = false })
|
||||
|
||||
// ===== 외화 결제 =====
|
||||
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
|
||||
@@ -99,14 +123,31 @@ watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
|
||||
if (isForeign.value) form.amount = convertedKrw.value
|
||||
})
|
||||
const fxLoading = ref(false)
|
||||
const rateUpdatedAt = ref(null) // 환율 마지막 조회 시각(ms)
|
||||
const tickNow = ref(Date.now())
|
||||
let _fxTick = null
|
||||
onMounted(() => { _fxTick = setInterval(() => { tickNow.value = Date.now() }, 30_000) })
|
||||
onUnmounted(() => clearInterval(_fxTick))
|
||||
const rateAgeLabel = computed(() => {
|
||||
if (!rateUpdatedAt.value || !isForeign.value) return null
|
||||
const diff = Math.floor((tickNow.value - rateUpdatedAt.value) / 1000)
|
||||
if (diff < 60) return '방금 업데이트'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
})
|
||||
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 = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
|
||||
if (res?.rate != null) {
|
||||
form.rate = Math.round(Number(res.rate) * 10000) / 10000
|
||||
rateUpdatedAt.value = res.updatedAt ? new Date(res.updatedAt).getTime() : Date.now()
|
||||
} else {
|
||||
formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
|
||||
}
|
||||
} catch {
|
||||
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
|
||||
} finally {
|
||||
@@ -120,6 +161,7 @@ function onCurrencyChange() {
|
||||
} else {
|
||||
form.foreignAmount = null
|
||||
form.rate = null
|
||||
rateUpdatedAt.value = null
|
||||
}
|
||||
}
|
||||
// 계좌/카드 (repayTargetIsCard·repayTargetLoanWallet computed가 참조하므로 먼저 선언)
|
||||
@@ -142,7 +184,7 @@ function monthlyInterestOf(loan) {
|
||||
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
|
||||
}
|
||||
|
||||
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
|
||||
// 상환방식별 자동계산: 원금균등·만기일시·원리금균등(PMT) 모두 자동, 나머지만 수동
|
||||
const loanAutoResult = computed(() => {
|
||||
const loan = repayTargetLoanWallet.value
|
||||
if (!loan) return null
|
||||
@@ -154,6 +196,24 @@ const loanAutoResult = computed(() => {
|
||||
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
|
||||
return { interest, principal, total: principal + interest, needsInput: false }
|
||||
}
|
||||
if (loan.loanMethod === 'EQUAL_PAYMENT') {
|
||||
// 월 상환금이 저장돼 있으면 그대로 사용(은행과 일치) → 없으면 원금·개월수로 PMT 추정
|
||||
let M = null
|
||||
if (loan.loanPayment) {
|
||||
M = Number(loan.loanPayment)
|
||||
} else if (loan.loanAmount && loan.loanMonths && loan.loanRate) {
|
||||
// PMT 공식: M = P × r(1+r)^n / ((1+r)^n − 1)
|
||||
const r = Number(loan.loanRate) / 100 / 12
|
||||
const n = loan.loanMonths
|
||||
M = Math.round(Number(loan.loanAmount) * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1))
|
||||
}
|
||||
if (M != null) {
|
||||
// 마지막 회차 등 잔액이 작을 땐 원금을 잔액까지만(초과 상환 방지)
|
||||
const bal = Math.abs(Number(loan.balance) || 0)
|
||||
const principal = Math.min(Math.max(0, M - interest), bal)
|
||||
return { interest, principal, total: principal + interest, needsInput: false }
|
||||
}
|
||||
}
|
||||
return { interest: null, principal: null, total: null, needsInput: true }
|
||||
})
|
||||
|
||||
@@ -250,6 +310,35 @@ async function runOcr(image) {
|
||||
formError.value = null
|
||||
try {
|
||||
const blob = await imageToBlob(image)
|
||||
// 1) AI 비전 우선 — 금액·상호·날짜·분류·계좌를 한 번에 구조화
|
||||
let ai = null
|
||||
try {
|
||||
ai = await accountApi.parseReceiptAi(blob, {
|
||||
categoryHints: allCategoryNames.value,
|
||||
walletHints: wallets.value.map((w) => w.name),
|
||||
})
|
||||
} catch {
|
||||
ai = null // AI 실패 → 아래 OCR 폴백
|
||||
}
|
||||
if (ai && ai.recognized) {
|
||||
form.type = ai.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
|
||||
if (ai.amount) form.amount = Number(ai.amount)
|
||||
if (ai.merchant && !form.memo) form.memo = ai.merchant
|
||||
if (ai.date) form.entryDate = ai.date
|
||||
if (ai.category) form.category = ai.category
|
||||
let aiCard = null
|
||||
if (ai.wallet) {
|
||||
const w = wallets.value.find((x) => x.name === ai.wallet)
|
||||
if (w) {
|
||||
form.walletKind = w.type
|
||||
form.walletId = w.id
|
||||
aiCard = w.name
|
||||
}
|
||||
}
|
||||
ocrResult.value = { amount: ai.amount, date: ai.date, store: ai.merchant, card: aiCard, category: ai.category, ai: true }
|
||||
return
|
||||
}
|
||||
// 2) 폴백 — 기존 Google Vision OCR + 규칙 파서
|
||||
const { text } = await accountApi.ocrReceipt(blob)
|
||||
const r = parseReceiptText(text)
|
||||
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
|
||||
@@ -298,6 +387,20 @@ const WALLET_KINDS = [
|
||||
{ value: 'MINUS', label: '마이너스' },
|
||||
{ value: 'INVEST', label: '증권' },
|
||||
]
|
||||
// 출금/결제 계좌 종류 뱃지에서는 대출 제외 — 대출은 지출·이체의 '출처'가 아니라 상환 대상
|
||||
// 출금/결제 계좌 종류: 대출은 항상 제외(상환 대상), 카드는 '지출'(카드결제)일 때만 노출.
|
||||
// 수입·이체·상환은 카드/대출에서 나가지 않음.
|
||||
const fromWalletKinds = computed(() =>
|
||||
WALLET_KINDS.filter((k) => {
|
||||
if (k.value === 'LOAN') return false
|
||||
if (k.value === 'CARD') return form.type === 'EXPENSE'
|
||||
return true
|
||||
}),
|
||||
)
|
||||
// 이체 입금 계좌 종류: 카드·대출은 이체가 아니라 '상환' 대상이므로 제외
|
||||
const toWalletKinds = computed(() =>
|
||||
WALLET_KINDS.filter((k) => k.value !== 'CARD' && k.value !== 'LOAN'),
|
||||
)
|
||||
const TYPES = [
|
||||
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
|
||||
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
|
||||
@@ -306,6 +409,20 @@ const TYPES = [
|
||||
]
|
||||
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
||||
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
||||
// 칩 선택용 옵션(계좌 선택을 select→칩으로)
|
||||
function walletOpts(list) {
|
||||
return list.map((w) => ({ value: w.id, label: w.name || w.issuer }))
|
||||
}
|
||||
// 출금/결제 계좌: 현금 지출·수입은 '미지정' 선택 허용
|
||||
const fromWalletOptions = computed(() => {
|
||||
const opts = walletOpts(walletsOfKind.value)
|
||||
if (form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE')) {
|
||||
opts.unshift({ value: '', label: '현금(미지정)' })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
const toWalletOptions = computed(() => walletOpts(toWalletsOfKind.value))
|
||||
const repayTargetOptions = computed(() => walletOpts(liabilityWallets.value))
|
||||
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
|
||||
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
|
||||
function walletKindOf(id) {
|
||||
@@ -338,11 +455,35 @@ async function loadCategories() {
|
||||
// 필터용 전체 분류명 (중복 제거)
|
||||
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
||||
|
||||
// 구분(수입/지출/이체) 변경 시 분류 초기화
|
||||
// 구분(수입/지출/이체) 변경 시 분류 초기화 + 새 구분에서 허용 안 되는 계좌 종류면 초기화
|
||||
function onTypeChange() {
|
||||
form.category = ''
|
||||
if (!fromWalletKinds.value.some((k) => k.value === form.walletKind)) {
|
||||
form.walletKind = ''
|
||||
form.walletId = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 검색·필터 바텀시트 옵션(맨 앞 '전체'로 해제 가능)
|
||||
const filterTypeOptions = [
|
||||
{ value: '', label: '구분 전체' },
|
||||
{ value: 'INCOME', label: '수입' },
|
||||
{ value: 'EXPENSE', label: '지출' },
|
||||
{ value: 'TRANSFER', label: '이체' },
|
||||
]
|
||||
const filterCategoryOptions = computed(() => [
|
||||
{ value: '', label: '분류 전체' },
|
||||
...allCategoryNames.value.map((c) => ({ value: c, label: c })),
|
||||
])
|
||||
const filterWalletOptions = computed(() => [
|
||||
{ value: '', label: '계좌 전체' },
|
||||
...wallets.value.map((w) => ({ value: w.id, label: w.name })),
|
||||
])
|
||||
const filterTagOptions = computed(() => [
|
||||
{ value: '', label: '태그 전체' },
|
||||
...tagOptions.value.map((t) => ({ value: t.id, label: t.name })),
|
||||
])
|
||||
|
||||
// 태그 (태그 관리에 등록된 태그 선택)
|
||||
const tagOptions = ref([]) // [{ id, name }]
|
||||
const selectedTagIds = ref([])
|
||||
@@ -471,12 +612,19 @@ function openCreate() {
|
||||
editId.value = null
|
||||
editingPending.value = false
|
||||
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
|
||||
rateUpdatedAt.value = null
|
||||
selectedTagIds.value = []
|
||||
formError.value = null
|
||||
formInfo.value = ''
|
||||
formOpen.value = true
|
||||
openForm()
|
||||
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
|
||||
}
|
||||
// 내역 화면에서 바로 빠른입력: 추가 폼을 준비해 열고, 그 위에 빠른입력 모달을 띄운다.
|
||||
// 분석·채우기 후 빠른입력 모달은 닫히고, 채워진 추가 폼이 남아 확인·저장.
|
||||
function openQuickInput() {
|
||||
openCreate()
|
||||
openPasteModal()
|
||||
}
|
||||
function openEdit(e) {
|
||||
editId.value = e.id
|
||||
editingPending.value = !!e.pending
|
||||
@@ -505,12 +653,13 @@ function openEdit(e) {
|
||||
const nameToId = {}
|
||||
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
|
||||
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
rateUpdatedAt.value = null
|
||||
pasteDetected.value = null
|
||||
pasteModalOpen.value = false
|
||||
pasteText.value = ''
|
||||
formError.value = null
|
||||
formInfo.value = ''
|
||||
formOpen.value = true
|
||||
openForm()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -552,7 +701,7 @@ async function submit() {
|
||||
if (editId.value) {
|
||||
await accountApi.remove(editId.value)
|
||||
}
|
||||
formOpen.value = false
|
||||
closeForm()
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
@@ -602,7 +751,7 @@ async function submit() {
|
||||
} else {
|
||||
await accountApi.create(payload)
|
||||
}
|
||||
formOpen.value = false
|
||||
closeForm()
|
||||
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
|
||||
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
|
||||
await load()
|
||||
@@ -624,7 +773,7 @@ async function remove(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 고정지출 등록 확인 모달 상태 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
|
||||
// 정기결제 등록 확인 모달 상태 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
|
||||
const recurConfirm = reactive({ open: false, title: '', dom: 1, amount: 0, payload: null })
|
||||
const flashMsg = ref('')
|
||||
let flashTimer = null
|
||||
@@ -634,9 +783,9 @@ function flash(msg) {
|
||||
flashTimer = setTimeout(() => (flashMsg.value = ''), 2800)
|
||||
}
|
||||
|
||||
// 현재 수정 중인 내역을 '매월' 고정지출로 등록 — 확인 모달을 띄운다
|
||||
// 현재 수정 중인 내역을 '매월' 정기결제로 등록 — 확인 모달을 띄운다
|
||||
function registerAsRecurring() {
|
||||
if (form.type === 'REPAYMENT') return // 고정지출은 수입/지출/이체만
|
||||
if (form.type === 'REPAYMENT') return // 정기결제은 수입/지출/이체만
|
||||
const amount = Number(form.amount)
|
||||
if (!amount || amount <= 0) {
|
||||
formError.value = '금액을 올바르게 입력하세요.'
|
||||
@@ -645,7 +794,7 @@ function registerAsRecurring() {
|
||||
const title =
|
||||
(form.memo || '').trim() ||
|
||||
(form.category || '').trim() ||
|
||||
(form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '고정 지출')
|
||||
(form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '정기결제')
|
||||
const base = form.entryDate || todayStr()
|
||||
const [y, m, d] = base.split('-').map(Number)
|
||||
const dom = d
|
||||
@@ -677,10 +826,10 @@ async function doRegisterRecurring() {
|
||||
try {
|
||||
await accountApi.createRecurring(recurConfirm.payload)
|
||||
recurConfirm.open = false
|
||||
formOpen.value = false
|
||||
flash('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
|
||||
closeForm()
|
||||
flash('정기결제로 등록했습니다. 정기결제 화면에서 주기를 변경할 수 있어요.')
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.'
|
||||
formError.value = e.response?.data?.message || '정기결제 등록에 실패했습니다.'
|
||||
recurConfirm.open = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -770,57 +919,79 @@ const pasteText = ref('')
|
||||
const pasteError = ref('')
|
||||
function applyParsed(p) {
|
||||
if (!p || !p.recognized) return
|
||||
form.type = p.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
|
||||
if (p.amount) form.amount = Number(p.amount)
|
||||
if (p.merchant) form.memo = p.merchant
|
||||
if (p.date) form.entryDate = p.date
|
||||
categoryMajor.value = ''
|
||||
// 계좌 매칭: 출금(from) / 받는·대상(to)
|
||||
const fromW = p.wallet ? wallets.value.find((x) => x.name === p.wallet) : null
|
||||
const toW = p.toWallet ? wallets.value.find((x) => x.name === p.toWallet) : null
|
||||
// 이체 대상이 대출/카드면 '상환(REPAYMENT)'으로 재분류 — 앱은 계좌→대출/카드 납입을 상환으로 처리(원금=이체·이자=지출)
|
||||
const isTransfer = p.type === 'TRANSFER'
|
||||
const repayment = isTransfer && toW && (toW.type === 'LOAN' || toW.type === 'CARD')
|
||||
form.type =
|
||||
p.type === 'INCOME' ? 'INCOME' : repayment ? 'REPAYMENT' : isTransfer ? 'TRANSFER' : 'EXPENSE'
|
||||
// 이체·상환은 분류 없음. 그 외엔 AI 추천 분류(후보 중)
|
||||
form.category = isTransfer || repayment ? '' : p.category || ''
|
||||
if (fromW) {
|
||||
form.walletKind = fromW.type
|
||||
form.walletId = fromW.id
|
||||
}
|
||||
if ((isTransfer || repayment) && toW) {
|
||||
form.toWalletKind = toW.type
|
||||
form.toWalletId = toW.id
|
||||
}
|
||||
if (repayment) {
|
||||
const amt = p.amount ? Number(p.amount) : null
|
||||
// toWalletId 변경 감시자가 principal/interest/납입금액을 초기화한 '뒤'에 값을 넣는다.
|
||||
nextTick(() => {
|
||||
const loan = repayTargetLoanWallet.value
|
||||
if (loan && loanAutoResult.value && loanAutoResult.value.needsInput && amt) {
|
||||
// 금리 설정된 대출(원리금균등) → 납입금액에 넣으면 이자/원금 자동 분리(calcLoanRepayment)
|
||||
loanPaymentAmount.value = amt
|
||||
} else if (!loan && amt) {
|
||||
// 금리 미설정 대출·카드 → 전액 원금(이자는 사용자가 분리 입력)
|
||||
form.principal = amt
|
||||
form.interest = null
|
||||
}
|
||||
// BULLET(만기일시)·EQUAL_PRINCIPAL(원금균등)은 loanAutoResult 감시자가 이자/원금을 자동 채움
|
||||
})
|
||||
}
|
||||
pasteDetected.value = null
|
||||
pasteModalOpen.value = false
|
||||
pasteText.value = ''
|
||||
pasteError.value = ''
|
||||
openForm() // 내역 화면에서 바로 빠른입력한 경우에도 채워진 추가 폼이 보이도록 보장
|
||||
}
|
||||
async function parseAndApply(text, { silent } = {}) {
|
||||
const t = (text || '').trim()
|
||||
if (!t) return false
|
||||
try {
|
||||
const p = await accountApi.parseText({ text: t })
|
||||
// 사용자의 실제 분류·계좌 이름을 함께 보내 AI가 그 중에서 고르게 함
|
||||
const p = await accountApi.parseText({
|
||||
text: t,
|
||||
categoryHints: allCategoryNames.value,
|
||||
walletHints: wallets.value.map((w) => w.name),
|
||||
})
|
||||
if (p && p.recognized) {
|
||||
if (silent) pasteDetected.value = p
|
||||
else applyParsed(p)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 무시
|
||||
} catch (e) {
|
||||
// 네트워크 오류는 조용히 폴백하되, 예외는 콘솔에 남겨 원인 추적이 가능하게 한다
|
||||
console.error('[parseAndApply] 실패:', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시) → 버튼 배지로 안내
|
||||
async function tryClipboardOnOpen() {
|
||||
// 추가 팝업 열 때: 클립보드 자동 읽기 제거(iOS 는 clipboard.readText() 마다 '붙여넣기' 시스템 토스트를 띄움).
|
||||
// 배지 표시용 감지도 하지 않는다. 붙여넣기는 사용자가 모달 안에서 직접 한다.
|
||||
function tryClipboardOnOpen() {
|
||||
pasteDetected.value = null
|
||||
pasteText.value = ''
|
||||
try {
|
||||
const txt = await navigator.clipboard.readText()
|
||||
await parseAndApply(txt, { silent: true })
|
||||
} catch {
|
||||
// 클립보드 접근 불가 — 버튼으로 수동
|
||||
}
|
||||
}
|
||||
// '문자·푸시' 버튼 → 모달 열기 + 클립보드 자동 읽기
|
||||
async function openPasteModal() {
|
||||
// '빠른 입력' 버튼 → 모달 열기(자동 클립보드 읽기 없음). 사용자가 자연어 입력 또는 직접 붙여넣기.
|
||||
function openPasteModal() {
|
||||
pasteError.value = ''
|
||||
pasteModalOpen.value = true
|
||||
if (!pasteText.value) {
|
||||
try {
|
||||
const txt = await navigator.clipboard.readText()
|
||||
if (txt) {
|
||||
pasteText.value = txt
|
||||
await parseAndApply(txt, { silent: true })
|
||||
}
|
||||
} catch {
|
||||
// 클립보드 접근 불가 — 수동 붙여넣기
|
||||
}
|
||||
}
|
||||
}
|
||||
// 모달에서 분석·채우기
|
||||
async function analyzePasted() {
|
||||
@@ -831,14 +1002,12 @@ async function analyzePasted() {
|
||||
|
||||
onMounted(async () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
||||
// 고정지출은 유료 전용 — 무료 회원은 호출 자체를 건너뛴다(403/업그레이드 리다이렉트 방지)
|
||||
if (isPremium.value) {
|
||||
// 정기결제은 무료도 2건까지 사용 가능 → 모든 로그인 사용자에 대해 실행
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadTagOptions()
|
||||
loadWallets()
|
||||
@@ -851,6 +1020,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<section class="account">
|
||||
<!-- 목록 뷰 (폼 열리면 숨김 → 폼이 페이지처럼 콘텐츠 영역을 차지) -->
|
||||
<template v-if="!formOpen">
|
||||
<header v-if="pendingCount" class="account-head">
|
||||
<span class="pending-count">확인 필요 {{ pendingCount }}건</span>
|
||||
</header>
|
||||
@@ -895,24 +1066,24 @@ onMounted(async () => {
|
||||
v-model="filters.keyword" type="text" class="f-keyword"
|
||||
placeholder="메모·분류 검색" @keyup.enter="applyFilters"
|
||||
/>
|
||||
<select v-model="filters.type">
|
||||
<option value="">구분 전체</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
<select v-model="filters.category">
|
||||
<option value="">분류 전체</option>
|
||||
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="filters.walletId">
|
||||
<option value="">계좌 전체</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
<select v-model="filters.tagId">
|
||||
<option value="">태그 전체</option>
|
||||
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<div class="f-sel"><SheetSelect v-model="filters.type" :options="filterTypeOptions" title="구분" placeholder="구분 전체" /></div>
|
||||
<div class="f-sel">
|
||||
<button type="button" class="sheet-trigger" :class="{ empty: !filters.category }" @click="catFilterSheet = true">
|
||||
<span class="st-label">{{ filters.category || '분류 전체' }}</span>
|
||||
<span class="st-caret">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
<BottomSheet v-model="catFilterSheet" title="분류 선택">
|
||||
<button type="button" class="cat-all-btn" :class="{ active: !filters.category }" @click="filters.category = ''; catFilterSheet = false">분류 전체</button>
|
||||
<CategoryPicker
|
||||
v-model="filters.category"
|
||||
:type="filters.type || 'EXPENSE'"
|
||||
:categories="categories"
|
||||
hide-add
|
||||
/>
|
||||
</BottomSheet>
|
||||
<div class="f-sel"><AccountPicker v-model="filters.walletId" :wallets="wallets" all-label="계좌 전체" title="계좌 선택" /></div>
|
||||
<div class="f-sel"><SheetSelect v-model="filters.tagId" :options="filterTagOptions" title="태그" placeholder="태그 전체" /></div>
|
||||
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
|
||||
<IconBtn icon="refresh" title="초기화" @click="resetFilters" />
|
||||
</div>
|
||||
@@ -932,6 +1103,16 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 자연어 빠른 입력 진입점(AI): 한 줄 입력 → 폼 자동 채움 -->
|
||||
<button type="button" class="quick-input-bar" @click="openQuickInput">
|
||||
<span class="qib-icon" aria-hidden="true">✨</span>
|
||||
<span class="qib-body">
|
||||
<span class="qib-title">빠른 입력</span>
|
||||
<span class="qib-sub">“어제 삼성카드로 라면 5천원” 처럼 한 줄로</span>
|
||||
</span>
|
||||
<span class="qib-plus" aria-hidden="true">+</span>
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
@@ -986,15 +1167,12 @@ onMounted(async () => {
|
||||
<p class="empty-desc">첫 수입·지출을 기록해보세요.</p>
|
||||
<button type="button" class="empty-cta" @click="openCreate">+ 첫 내역 추가</button>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
|
||||
<div class="modal entry-modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||
|
||||
</template>
|
||||
<!-- 내역 추가/수정: 목록 대신 콘텐츠 영역에 인라인 페이지로(헤더·하단바 유지, 뒤로가기로 닫힘) -->
|
||||
<div v-else class="entry-page">
|
||||
<header class="ep-head">
|
||||
<span class="ep-title">{{ editId ? '내역 수정' : '내역 추가' }}</span>
|
||||
</header>
|
||||
<form class="entry-form" @submit.prevent="submit">
|
||||
<!-- 영수증 스캔 · 문자/푸시 (같은 행 상단) -->
|
||||
<div v-if="!isRepayment" class="receipt-box">
|
||||
@@ -1012,17 +1190,19 @@ onMounted(async () => {
|
||||
<button
|
||||
type="button" class="paste-btn" :class="{ detected: pasteDetected }"
|
||||
:disabled="submitting" @click="openPasteModal"
|
||||
>📋 문자·푸시<span v-if="pasteDetected" class="pb-dot">●</span></button>
|
||||
>✨ 빠른 입력<span v-if="pasteDetected" class="pb-dot">●</span></button>
|
||||
</div>
|
||||
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
||||
<span v-else-if="ocrResult" class="receipt-status ok">
|
||||
<template v-if="ocrResult.ai">✨ </template>
|
||||
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}원</template>
|
||||
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
|
||||
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
|
||||
<template v-if="ocrResult.category"> · {{ ocrResult.category }}</template>
|
||||
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
|
||||
자동 입력됨
|
||||
</span>
|
||||
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
|
||||
<span v-else class="receipt-hint">사진에서 금액·날짜·상호·분류를 자동 입력</span>
|
||||
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
|
||||
</div>
|
||||
|
||||
@@ -1042,18 +1222,20 @@ onMounted(async () => {
|
||||
<div class="field">
|
||||
<span class="field-label">계좌 종류</span>
|
||||
<div class="wallet-chips">
|
||||
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
|
||||
<button v-for="k in fromWalletKinds" :key="k.value" type="button"
|
||||
class="chip" :class="{ active: form.walletKind === k.value }"
|
||||
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
|
||||
{{ k.label }}
|
||||
</button>
|
||||
</div>
|
||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option>
|
||||
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name || w.issuer }}
|
||||
</option>
|
||||
</select>
|
||||
<SheetSelect
|
||||
v-if="form.walletKind"
|
||||
v-model="form.walletId"
|
||||
:options="fromWalletOptions"
|
||||
:disabled="submitting"
|
||||
title="계좌 선택" placeholder="계좌를 선택하세요"
|
||||
:empty-text="`등록된 ${walletKindLabel}이(가) 없습니다`"
|
||||
/>
|
||||
</div>
|
||||
<!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) -->
|
||||
<label v-if="showInstallment">할부
|
||||
@@ -1067,31 +1249,35 @@ onMounted(async () => {
|
||||
<div class="field">
|
||||
<span class="field-label">입금 종류</span>
|
||||
<div class="wallet-chips">
|
||||
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
|
||||
<button v-for="k in toWalletKinds" :key="k.value" type="button"
|
||||
class="chip" :class="{ active: form.toWalletKind === k.value }"
|
||||
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
|
||||
{{ k.label }}
|
||||
</button>
|
||||
</div>
|
||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">입금 계좌 선택</option>
|
||||
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name || w.issuer }}
|
||||
</option>
|
||||
</select>
|
||||
<SheetSelect
|
||||
v-if="form.toWalletKind"
|
||||
v-model="form.toWalletId"
|
||||
:options="toWalletOptions"
|
||||
:disabled="submitting"
|
||||
title="입금 계좌 선택" placeholder="계좌를 선택하세요"
|
||||
empty-text="등록된 계좌가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 (+카드면 연회비). 원금=이체·이자/연회비=지출 -->
|
||||
<template v-if="isRepayment">
|
||||
<label>대상(대출/카드)
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
|
||||
{{ w.name || w.issuer }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">대상(대출/카드)</span>
|
||||
<AccountPicker
|
||||
v-model="form.toWalletId"
|
||||
:wallets="liabilityWallets"
|
||||
:disabled="submitting"
|
||||
title="상환 대상 선택" placeholder="대출/카드를 선택하세요"
|
||||
empty-text="등록된 대출/카드가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
|
||||
<template v-if="repayTargetLoanWallet">
|
||||
<!-- 원리금균등: 납입금액 직접 입력 -->
|
||||
@@ -1123,7 +1309,17 @@ onMounted(async () => {
|
||||
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
|
||||
</template>
|
||||
|
||||
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
||||
<div v-if="form.type === 'INCOME' || form.type === 'EXPENSE'" class="field">
|
||||
<span class="field-label">분류</span>
|
||||
<button
|
||||
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
|
||||
:disabled="submitting" @click="catSheet = true"
|
||||
>
|
||||
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
|
||||
<span class="st-caret">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
<BottomSheet v-model="catSheet" title="분류 선택">
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
:type="form.type"
|
||||
@@ -1131,7 +1327,7 @@ onMounted(async () => {
|
||||
:disabled="submitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</label>
|
||||
</BottomSheet>
|
||||
<label v-if="!isRepayment" class="amount-label">
|
||||
금액
|
||||
<div class="amount-row">
|
||||
@@ -1149,6 +1345,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
<span v-if="rateAgeLabel" class="fx-age">{{ rateAgeLabel }}</span>
|
||||
</div>
|
||||
<span class="fx-krw">= ₩{{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
|
||||
</div>
|
||||
@@ -1178,7 +1375,7 @@ onMounted(async () => {
|
||||
class="to-recurring"
|
||||
:disabled="submitting"
|
||||
@click="registerAsRecurring"
|
||||
>🔁 이 내역을 고정지출로 등록</button>
|
||||
>🔁 이 내역을 정기결제로 등록</button>
|
||||
|
||||
<button
|
||||
v-if="editId && (form.type === 'INCOME' || form.type === 'EXPENSE')"
|
||||
@@ -1189,14 +1386,11 @@ onMounted(async () => {
|
||||
>⭐ 자주 쓰는 내역으로 저장</button>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="close" title="취소" @click="closeForm()" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 영수증 등록 방식 선택 (카메라/갤러리) -->
|
||||
<Teleport to="body">
|
||||
@@ -1222,17 +1416,17 @@ onMounted(async () => {
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 고정지출 등록 확인 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
||||
<!-- 정기결제 등록 확인 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="recurConfirm.open" class="modal-backdrop" @click.self="recurConfirm.open = false">
|
||||
<div class="modal confirm-modal" role="dialog" aria-modal="true">
|
||||
<h2>고정지출 등록</h2>
|
||||
<h2>정기결제 등록</h2>
|
||||
<p class="confirm-body">
|
||||
<b>{{ recurConfirm.title }}</b><br />
|
||||
매월 <b>{{ recurConfirm.dom }}일</b> · <b>{{ won(recurConfirm.amount) }}</b> 으로 등록할까요?
|
||||
</p>
|
||||
<p class="confirm-sub">주기·시작일은 고정지출 화면에서 변경할 수 있어요.</p>
|
||||
<p class="confirm-sub">주기·시작일은 정기결제 화면에서 변경할 수 있어요.</p>
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="recurConfirm.open = false" />
|
||||
<IconBtn icon="save" title="등록" variant="primary" :disabled="submitting" @click="doRegisterRecurring" />
|
||||
@@ -1249,16 +1443,16 @@ onMounted(async () => {
|
||||
<div v-if="pasteModalOpen" class="modal-backdrop quick-picker-backdrop" @click.self="pasteModalOpen = false">
|
||||
<div class="modal paste-modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="pasteModalOpen = false">×</button>
|
||||
<h2>문자·푸시 붙여넣기</h2>
|
||||
<p class="pm-hint">문자/푸시 내용을 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 입력됩니다.</p>
|
||||
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="여기에 붙여넣기 (예: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27)"></textarea>
|
||||
<div v-if="pasteDetected" class="paste-detected">
|
||||
<h2>빠른 입력</h2>
|
||||
<p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p>
|
||||
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원 또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27"></textarea>
|
||||
<div v-if="pasteDetected && pasteDetected.amount" class="paste-detected">
|
||||
감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
|
||||
</div>
|
||||
<p v-if="pasteError" class="msg error">{{ pasteError }}</p>
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="pasteModalOpen = false" />
|
||||
<IconBtn icon="save" title="분석·채우기" variant="primary" @click="analyzePasted" />
|
||||
<div class="buttons pm-buttons">
|
||||
<button type="button" class="pm-btn pm-cancel" @click="pasteModalOpen = false">취소</button>
|
||||
<button type="button" class="pm-btn pm-primary" @click="analyzePasted">✨ 분석해서 채우기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1360,11 +1554,113 @@ button.primary {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.filter-bar .f-sel {
|
||||
flex: 1 1 7rem;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
/* 시트 트리거(필드형) — 분류 선택 등 */
|
||||
.sheet-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sheet-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sheet-trigger.empty .st-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.st-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-caret {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-all-btn {
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cat-all-btn.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
/* 자연어 빠른 입력 바 */
|
||||
.quick-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 0.45);
|
||||
border-radius: 12px;
|
||||
background: hsla(160, 100%, 37%, 0.08);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.quick-input-bar:hover {
|
||||
background: hsla(160, 100%, 37%, 0.14);
|
||||
}
|
||||
.quick-input-bar .qib-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.quick-input-bar .qib-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.quick-input-bar .qib-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: hsla(160, 100%, 32%, 1);
|
||||
}
|
||||
.quick-input-bar .qib-sub {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.quick-input-bar .qib-plus {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -1654,6 +1950,25 @@ button.primary {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
|
||||
/* 내역 추가/수정 인라인 페이지 (헤더·하단바 유지) */
|
||||
.entry-page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ep-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.ep-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.entry-page .entry-form {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.entry-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1699,9 +2014,12 @@ button.primary {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
/* backdrop 이 flex 라 모달(flex 아이템) 기본 min-width:auto 가 내용물만큼 모달을 뷰포트보다 넓힘 → 0 으로 고정 */
|
||||
min-width: 0;
|
||||
/* 폼이 길어도 모달 안에서 스크롤되도록 + 하단 소프트키/제스처바 안전영역 확보 */
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* 내부 요소가 가로로 넘쳐도 페이지가 밀리지 않게(넓은 그리드·입력행 방지) */
|
||||
padding: 1.75rem 1.5rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -2019,6 +2337,11 @@ button.primary {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
.fx-age {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-soft, #999);
|
||||
margin-top: 0.18rem;
|
||||
}
|
||||
.row-wallet {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
@@ -2120,6 +2443,32 @@ button.primary {
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
|
||||
.paste-modal .pm-buttons {
|
||||
bottom: 0;
|
||||
gap: 0.6rem;
|
||||
padding-bottom: calc(0.2rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
.pm-btn {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.pm-cancel {
|
||||
flex: 0 0 auto;
|
||||
min-width: 84px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pm-primary {
|
||||
flex: 1;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.to-recurring {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const walletId = Number(route.params.id)
|
||||
|
||||
const wallet = ref(null)
|
||||
const entries = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const month = ref('') // YYYY-MM
|
||||
|
||||
const TYPE_LABEL = {
|
||||
BANK: '은행계좌',
|
||||
CASH: '현금',
|
||||
CARD: '신용/체크카드',
|
||||
LOAN: '대출',
|
||||
MINUS: '마이너스통장',
|
||||
INVEST: '투자',
|
||||
}
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function currentYm() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
function ymLabel(ym) {
|
||||
return ym ? ym.replace('-', '.') : ''
|
||||
}
|
||||
function cardTypeLabel(v) {
|
||||
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
|
||||
}
|
||||
function loanMethodLabel(v) {
|
||||
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
|
||||
}
|
||||
|
||||
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
|
||||
function effectAmount(e) {
|
||||
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
|
||||
return e.type === 'INCOME' ? e.amount : -e.amount
|
||||
}
|
||||
function entryDesc(e) {
|
||||
const m = e.memo ? ` · ${e.memo}` : ''
|
||||
const w = wallet.value
|
||||
if (e.type === 'TRANSFER') {
|
||||
if (e.toWalletId === walletId) {
|
||||
const inLabel = w && isLiability(w.type) ? '상환/납부' : '입금'
|
||||
return `${inLabel} ← ${e.walletName || '계좌'}${m}`
|
||||
}
|
||||
return `이체 → ${e.toWalletName || '계좌'}${m}`
|
||||
}
|
||||
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
|
||||
return `${e.category || '지출'}${m}`
|
||||
}
|
||||
function entryDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime())
|
||||
? v
|
||||
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const monthListArr = computed(() => {
|
||||
const set = new Set(entries.value.map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
|
||||
return [...set].sort()
|
||||
})
|
||||
const monthEntries = computed(() =>
|
||||
entries.value.filter((e) => (e.entryDate || '').slice(0, 7) === month.value),
|
||||
)
|
||||
const monthSum = computed(() => monthEntries.value.reduce((s, e) => s + effectAmount(e), 0))
|
||||
const canPrev = computed(() => monthListArr.value.indexOf(month.value) > 0)
|
||||
const canNext = computed(() => {
|
||||
const i = monthListArr.value.indexOf(month.value)
|
||||
return i >= 0 && i < monthListArr.value.length - 1
|
||||
})
|
||||
function shiftMonth(delta) {
|
||||
const ms = monthListArr.value
|
||||
if (!ms.length) return
|
||||
const i = ms.indexOf(month.value)
|
||||
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
|
||||
month.value = ms[j]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const ws = await accountApi.wallets()
|
||||
wallet.value = ws.find((w) => w.id === walletId) || null
|
||||
if (!wallet.value) {
|
||||
error.value = '계좌를 찾을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
if (wallet.value.type !== 'INVEST') {
|
||||
entries.value = await accountApi.walletEntries(walletId)
|
||||
const ms = monthListArr.value
|
||||
month.value = ms.length ? ms[ms.length - 1] : currentYm()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wdetail">
|
||||
<header class="wd-head">
|
||||
<span class="wd-title">{{ wallet?.name || '계좌' }}</span>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else-if="wallet">
|
||||
<!-- 계좌 요약 카드 -->
|
||||
<div class="wd-summary">
|
||||
<div class="wd-sum-top">
|
||||
<span class="wd-type">{{ TYPE_LABEL[wallet.type] }}</span>
|
||||
<span v-if="wallet.type === 'CARD' && wallet.cardType" class="wd-badge">{{ cardTypeLabel(wallet.cardType) }}</span>
|
||||
</div>
|
||||
<div class="wd-figure" :class="{ neg: wallet.type === 'INVEST' ? false : wallet.balance < 0 }">
|
||||
<template v-if="wallet.type === 'INVEST'">투자금 {{ won(wallet.investedAmount) }}</template>
|
||||
<template v-else>{{ won(wallet.balance) }}</template>
|
||||
</div>
|
||||
<div class="wd-meta">
|
||||
<span v-if="wallet.issuer">{{ wallet.issuer }}</span>
|
||||
<span v-if="wallet.type === 'LOAN' && wallet.loanRate">
|
||||
· {{ wallet.loanRate }}%<template v-if="wallet.loanMethod"> / {{ loanMethodLabel(wallet.loanMethod) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 투자: 내역 대신 안내 -->
|
||||
<div v-if="wallet.type === 'INVEST'" class="wd-invest-note">
|
||||
<p>투자금(투입원금)을 표시합니다. 금액 변경은 계좌 관리에서 <b>수정</b>하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 그 외: 월별 내역 -->
|
||||
<template v-else>
|
||||
<div v-if="monthListArr.length" class="wd-month-nav">
|
||||
<button type="button" class="mn-btn" :disabled="!canPrev" aria-label="이전 달" @click="shiftMonth(-1)">◀</button>
|
||||
<span class="mn-label">{{ ymLabel(month) }}</span>
|
||||
<button type="button" class="mn-btn" :disabled="!canNext" aria-label="다음 달" @click="shiftMonth(1)">▶</button>
|
||||
<span class="mn-sum" :class="monthSum < 0 ? 'neg' : 'pos'">
|
||||
합계 {{ monthSum >= 0 ? '+' : '' }}{{ won(monthSum) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul v-if="monthEntries.length" class="wd-list">
|
||||
<li v-for="e in monthEntries" :key="e.id" class="wd-row">
|
||||
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
|
||||
<span class="d-desc">{{ entryDesc(e) }}</span>
|
||||
<span class="d-amount" :class="effectAmount(e) < 0 ? 'neg' : 'pos'">
|
||||
{{ effectAmount(e) >= 0 ? '+' : '' }}{{ won(effectAmount(e)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="monthListArr.length" class="msg">이 달 내역이 없습니다.</p>
|
||||
<p v-else class="msg">연관 내역이 없습니다.</p>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wdetail {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wd-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.wd-summary {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wd-sum-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wd-type {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wd-badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wd-figure {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wd-figure.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.wd-meta {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.wd-invest-note {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.75;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wd-month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.mn-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 6px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.mn-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.mn-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.mn-sum {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mn-sum.neg,
|
||||
.d-amount.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.mn-sum.pos,
|
||||
.d-amount.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.wd-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.wd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.d-date {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.6;
|
||||
width: 3rem;
|
||||
}
|
||||
.d-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.d-amount {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const TABS = [
|
||||
{ key: 'BANK', label: '은행계좌' },
|
||||
@@ -16,125 +19,67 @@ const TABS = [
|
||||
{ key: 'MINUS', label: '마이너스통장' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
const CARD_TYPES = [
|
||||
{ value: 'CREDIT', label: '신용카드' },
|
||||
{ value: 'CHECK', label: '체크카드' },
|
||||
]
|
||||
const LOAN_METHODS = [
|
||||
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
|
||||
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
|
||||
{ value: 'BULLET', label: '만기일시' },
|
||||
{ value: '', label: '선택안함' },
|
||||
]
|
||||
|
||||
const wallets = ref([])
|
||||
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('BANK')
|
||||
// 순서변경(잠금) 모드 — 평소엔 잠금(드래그·핸들 숨김), 켤 때만 드래그 가능
|
||||
const reorderMode = ref(false)
|
||||
// 종류별 그룹(탭 제거) — TABS 순서로, 계좌가 있는 종류만 노출
|
||||
const groups = computed(() =>
|
||||
TABS.map((t) => ({ ...t, wallets: wallets.value.filter((w) => w.type === t.key) })).filter(
|
||||
(g) => g.wallets.length,
|
||||
),
|
||||
)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
|
||||
const form = reactive({
|
||||
type: 'BANK',
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
// 대출(LOAN) 전용
|
||||
loanAmount: null,
|
||||
loanRate: null,
|
||||
loanMethod: '',
|
||||
loanMonths: null,
|
||||
loanStart: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 드롭다운(계좌별 내역) — 월별로 표시
|
||||
const expandedId = ref(null)
|
||||
const entriesByWallet = reactive({})
|
||||
const loadingEntries = ref(false)
|
||||
const entryMonth = reactive({}) // 계좌별 선택 월(YYYY-MM)
|
||||
|
||||
function currentYm() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
// 계좌 클릭 → 별도 상세 화면(계좌별 내역)
|
||||
function goDetail(w) {
|
||||
router.push(`/account/wallets/${w.id}`)
|
||||
}
|
||||
function ymLabel(ym) {
|
||||
return ym ? ym.replace('-', '.') : ''
|
||||
// 추가/수정은 별도 화면으로(모달 → 화면전환)
|
||||
function openCreate() {
|
||||
router.push('/account/wallets/new')
|
||||
}
|
||||
// 데이터가 있는 월 목록(오름차순)
|
||||
function monthList(w) {
|
||||
const set = new Set((entriesByWallet[w.id] || []).map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
|
||||
return [...set].sort()
|
||||
}
|
||||
function monthEntries(w) {
|
||||
const ym = entryMonth[w.id]
|
||||
return (entriesByWallet[w.id] || []).filter((e) => (e.entryDate || '').slice(0, 7) === ym)
|
||||
}
|
||||
function monthSum(w) {
|
||||
return monthEntries(w).reduce((s, e) => s + effectAmount(e, w.id), 0)
|
||||
}
|
||||
function canPrev(w) {
|
||||
return monthList(w).indexOf(entryMonth[w.id]) > 0
|
||||
}
|
||||
function canNext(w) {
|
||||
const ms = monthList(w)
|
||||
const i = ms.indexOf(entryMonth[w.id])
|
||||
return i >= 0 && i < ms.length - 1
|
||||
}
|
||||
// 데이터가 있는 월 사이로만 이동(빈 달 건너뜀)
|
||||
function shiftMonth(w, delta) {
|
||||
const ms = monthList(w)
|
||||
if (!ms.length) return
|
||||
const i = ms.indexOf(entryMonth[w.id])
|
||||
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
|
||||
entryMonth[w.id] = ms[j]
|
||||
function openEdit(w) {
|
||||
router.push(`/account/wallets/${w.id}/edit`)
|
||||
}
|
||||
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
|
||||
|
||||
// 현재 탭(activeType)의 계좌 — 드래그 정렬 대상 (백엔드가 sort_order 순 정렬)
|
||||
const rows = ref([])
|
||||
function syncRows() {
|
||||
rows.value = wallets.value.filter((w) => w.type === activeType.value)
|
||||
// ===== 드래그앤드랍 순서 변경 (종류별 그룹마다 SortableJS, 터치 지원) =====
|
||||
let sortables = []
|
||||
function destroySortables() {
|
||||
sortables.forEach((s) => s.destroy())
|
||||
sortables = []
|
||||
}
|
||||
watch([wallets, activeType], syncRows, { immediate: true })
|
||||
|
||||
// 탭 선택 (투자 계좌는 평가액 직접입력 방식 — 별도 시세갱신 없음)
|
||||
function selectTab(key) {
|
||||
activeType.value = key
|
||||
}
|
||||
|
||||
// ===== 드래그앤드랍 순서 변경 (SortableJS, 터치 지원) =====
|
||||
const listEl = ref(null)
|
||||
let sortable = null
|
||||
function initSortable() {
|
||||
if (sortable) {
|
||||
sortable.destroy()
|
||||
sortable = null
|
||||
}
|
||||
if (!listEl.value) return
|
||||
sortable = Sortable.create(listEl.value, {
|
||||
function initSortables() {
|
||||
destroySortables()
|
||||
if (!reorderMode.value) return // 잠금 상태에선 드래그 비활성화
|
||||
document.querySelectorAll('.wallet-group-list').forEach((el) => {
|
||||
sortables.push(
|
||||
Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex === evt.newIndex) return
|
||||
const arr = rows.value
|
||||
const moved = arr.splice(evt.oldIndex, 1)[0]
|
||||
arr.splice(evt.newIndex, 0, moved)
|
||||
persistOrder(arr.map((r) => r.id))
|
||||
const type = el.dataset.type
|
||||
const ids = [...el.querySelectorAll(':scope > .wallet-item')].map((li) => Number(li.dataset.id))
|
||||
// 낙관적 반영: Sortable 이 옮긴 DOM 순서에 맞춰 데이터도 재정렬(Vue 재렌더 정합)
|
||||
const byId = new Map(wallets.value.map((w) => [w.id, w]))
|
||||
const moved = ids.map((id) => byId.get(id)).filter(Boolean)
|
||||
wallets.value = [...moved, ...wallets.value.filter((w) => w.type !== type)]
|
||||
persistOrder(type, ids)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
async function persistOrder(ids) {
|
||||
// 순서변경 모드 토글 시 드래그 바인딩/해제
|
||||
watch(reorderMode, async () => {
|
||||
await nextTick()
|
||||
initSortables()
|
||||
})
|
||||
async function persistOrder(type, ids) {
|
||||
try {
|
||||
await accountApi.reorderWallets(activeType.value, ids)
|
||||
await accountApi.reorderWallets(type, ids)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
|
||||
@@ -142,52 +87,6 @@ async function persistOrder(ids) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(w) {
|
||||
if (expandedId.value === w.id) {
|
||||
expandedId.value = null
|
||||
return
|
||||
}
|
||||
expandedId.value = w.id
|
||||
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
|
||||
loadingEntries.value = true
|
||||
try {
|
||||
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
|
||||
// 기본 선택 월 = 내역이 있는 가장 최근 월(없으면 이번 달)
|
||||
const ms = monthList(w)
|
||||
entryMonth[w.id] = ms.length ? ms[ms.length - 1] : currentYm()
|
||||
} catch {
|
||||
entriesByWallet[w.id] = []
|
||||
} finally {
|
||||
loadingEntries.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
|
||||
function effectAmount(e, walletId) {
|
||||
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
|
||||
return e.type === 'INCOME' ? e.amount : -e.amount
|
||||
}
|
||||
function entryDesc(e, w) {
|
||||
const m = e.memo ? ` · ${e.memo}` : ''
|
||||
if (e.type === 'TRANSFER') {
|
||||
if (e.toWalletId === w.id) {
|
||||
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
|
||||
const inLabel = w.type === 'CARD' || w.type === 'LOAN' || w.type === 'MINUS' ? '상환/납부' : '입금'
|
||||
return `${inLabel} ← ${e.walletName || '계좌'}${m}`
|
||||
}
|
||||
return `이체 → ${e.toWalletName || '계좌'}${m}`
|
||||
}
|
||||
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
|
||||
return `${e.category || '지출'}${m}`
|
||||
}
|
||||
function entryDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime())
|
||||
? v
|
||||
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
@@ -210,22 +109,6 @@ function toggleReveal(id) {
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
revealedAccts.value = s
|
||||
}
|
||||
function issuerLabel(t) {
|
||||
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
}
|
||||
function openingLabel(t) {
|
||||
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
|
||||
if (t === 'CARD') return '초기 미결제 금액'
|
||||
if (t === 'INVEST') return '투자금(투입원금)'
|
||||
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
|
||||
return '기록 시작 시 잔액'
|
||||
}
|
||||
// 투자 수익률(%) — 투입원금 대비 평가손익
|
||||
function returnPct(w) {
|
||||
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
|
||||
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -238,88 +121,9 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
type: activeType.value,
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
loanAmount: null,
|
||||
loanRate: null,
|
||||
loanMethod: '',
|
||||
loanMonths: null,
|
||||
loanStart: '',
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(w) {
|
||||
editId.value = w.id
|
||||
Object.assign(form, {
|
||||
type: w.type,
|
||||
name: w.name,
|
||||
issuer: w.issuer || '',
|
||||
accountNumber: w.accountNumber || '',
|
||||
cardType: w.cardType || 'CREDIT',
|
||||
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
|
||||
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
||||
openingDate: w.openingDate || '',
|
||||
currentValue: w.currentValue ?? null,
|
||||
loanAmount: w.loanAmount ?? null,
|
||||
loanRate: w.loanRate ?? null,
|
||||
loanMethod: w.loanMethod || '',
|
||||
loanMonths: w.loanMonths ?? null,
|
||||
loanStart: w.loanStart || '',
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.name.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const magnitude = Number(form.openingBalance) || 0
|
||||
const isLoan = form.type === 'LOAN'
|
||||
const payload = {
|
||||
type: form.type,
|
||||
name: form.name.trim(),
|
||||
issuer: form.issuer || null,
|
||||
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
|
||||
cardType: form.type === 'CARD' ? form.cardType : null,
|
||||
// 부채는 음수로 저장 (갚을 금액)
|
||||
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
|
||||
openingDate: form.openingDate || null,
|
||||
currentValue:
|
||||
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
||||
? Number(form.currentValue)
|
||||
: null,
|
||||
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
|
||||
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
|
||||
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
|
||||
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
|
||||
loanStart: isLoan && form.loanStart ? form.loanStart : null,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateWallet(editId.value, payload)
|
||||
else await accountApi.createWallet(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
// 그룹 렌더 후 종류별 드래그 재바인딩
|
||||
await nextTick()
|
||||
initSortables()
|
||||
}
|
||||
|
||||
async function remove(w) {
|
||||
@@ -339,12 +143,8 @@ function loanMethodLabel(v) {
|
||||
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
})
|
||||
onBeforeUnmount(() => sortable?.destroy())
|
||||
onMounted(load)
|
||||
onBeforeUnmount(destroySortables)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -366,30 +166,32 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabbar">
|
||||
<div class="tabs">
|
||||
<div class="wallet-head">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
:class="{ active: activeType === t.key }"
|
||||
@click="selectTab(t.key)"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
|
||||
v-if="wallets.length > 1"
|
||||
type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }"
|
||||
@click="reorderMode = !reorderMode"
|
||||
>{{ reorderMode ? '완료' : '순서변경' }}</button>
|
||||
<IconBtn class="tab-add" icon="plus" title="계좌 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<p v-if="reorderMode" class="msg reorder-hint">≡ 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b>를 누르세요.</p>
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-show="!loading && rows.length" ref="listEl" class="wallet-list">
|
||||
<li v-for="w in rows" :key="w.id" :data-id="w.id" class="wallet-item">
|
||||
<div class="wallet-row" @click="toggleExpand(w)">
|
||||
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop>≡</span>
|
||||
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
|
||||
<template v-if="!loading && groups.length">
|
||||
<section v-for="g in groups" :key="g.key" class="wallet-group">
|
||||
<h3 class="wg-title">{{ g.label }}</h3>
|
||||
<ul class="wallet-list wallet-group-list" :data-type="g.key">
|
||||
<li v-for="w in g.wallets" :key="w.id" :data-id="w.id" class="wallet-item">
|
||||
<div class="wallet-row">
|
||||
<span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경">≡</span>
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="name">{{ w.name }}</span>
|
||||
<span
|
||||
class="name" :class="{ link: !reorderMode && w.type !== 'INVEST' }"
|
||||
@click="!reorderMode && w.type !== 'INVEST' && goDetail(w)"
|
||||
>{{ w.name }}</span>
|
||||
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
@@ -402,7 +204,6 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
@click.stop="toggleReveal(w.id)"
|
||||
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
|
||||
</template>
|
||||
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
|
||||
<template v-if="w.type === 'LOAN'">
|
||||
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}원</span>
|
||||
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
|
||||
@@ -410,134 +211,26 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
</span>
|
||||
</div>
|
||||
<div class="balance-wrap">
|
||||
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
|
||||
<div
|
||||
v-if="w.type === 'INVEST' && w.valuationGain != null"
|
||||
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
|
||||
>
|
||||
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
|
||||
<div v-if="w.type === 'INVEST'" class="balance">{{ won(w.investedAmount) }}</div>
|
||||
<div v-else class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" @click.stop>
|
||||
<div v-if="!reorderMode" class="actions">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- 드롭다운: 투자는 투자금·평가액 요약, 그 외는 계좌별 내역 -->
|
||||
<div v-if="expandedId === w.id" class="entry-drop">
|
||||
<div v-if="w.type === 'INVEST'" class="manual-note">
|
||||
<div class="invest-figs">
|
||||
<div class="fig"><span class="fig-k">투자금</span><span class="fig-v">{{ won(w.investedAmount) }}</span></div>
|
||||
<div class="fig"><span class="fig-k">평가액</span><span class="fig-v">{{ won(w.balance) }}</span></div>
|
||||
<div class="fig"><span class="fig-k">손익</span>
|
||||
<span class="fig-v" :class="(w.valuationGain || 0) < 0 ? 'neg' : 'pos'">
|
||||
{{ (w.valuationGain || 0) >= 0 ? '+' : '' }}{{ won(w.valuationGain || 0) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="manual-sub">평가액을 갱신하려면 위 <b>수정</b>에서 ‘현재 평가액’을 바꾸세요.</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
|
||||
<template v-else-if="(entriesByWallet[w.id] || []).length">
|
||||
<!-- 월 네비게이션 + 그 달 합계 -->
|
||||
<div class="month-nav">
|
||||
<button type="button" class="mn-btn" :disabled="!canPrev(w)" aria-label="이전 달" @click="shiftMonth(w, -1)">◀</button>
|
||||
<span class="mn-label">{{ ymLabel(entryMonth[w.id]) }}</span>
|
||||
<button type="button" class="mn-btn" :disabled="!canNext(w)" aria-label="다음 달" @click="shiftMonth(w, 1)">▶</button>
|
||||
<span class="mn-sum" :class="monthSum(w) < 0 ? 'neg' : 'pos'">
|
||||
합계 {{ monthSum(w) >= 0 ? '+' : '' }}{{ won(monthSum(w)) }}
|
||||
</span>
|
||||
</div>
|
||||
<ul v-if="monthEntries(w).length" class="drop-list">
|
||||
<li v-for="e in monthEntries(w)" :key="e.id" class="drop-row">
|
||||
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
|
||||
<span class="d-desc">{{ entryDesc(e, w) }}</span>
|
||||
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
|
||||
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="drop-msg">이 달 내역이 없습니다.</p>
|
||||
</template>
|
||||
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!loading && !rows.length" class="empty-state">
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<p class="empty-emoji">🏦</p>
|
||||
<p class="empty-title">아직 등록된 계좌가 없어요</p>
|
||||
<p class="empty-desc">은행·카드·현금·대출·마이너스통장·투자를 추가해보세요.</p>
|
||||
<button type="button" class="empty-cta" @click="openCreate">+ 계좌 추가</button>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<AppModal v-model="formOpen" :title="`${TABS.find((t) => t.key === form.type)?.label} ${editId ? '수정' : '추가'}`">
|
||||
<form class="wallet-form" @submit.prevent="submit">
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<div v-if="form.type === 'CARD'" class="field">
|
||||
<span class="field-label">카드 종류</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="c in CARD_TYPES" :key="c.value"
|
||||
type="button" class="chip" :class="{ active: form.cardType === c.value }"
|
||||
:disabled="submitting" @click="form.cardType = c.value"
|
||||
>{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<template v-if="form.type === 'INVEST'">
|
||||
<label>현재 평가액
|
||||
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
<b>투자금</b>(투입원금)과 <b>현재 평가액</b>만 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
|
||||
평가액은 위 <b>수정</b>에서 주기적으로 갱신하세요.
|
||||
</p>
|
||||
</template>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 대출 전용 -->
|
||||
<template v-if="form.type === 'LOAN'">
|
||||
<label>대출 실행 금액(원금)
|
||||
<input v-model.number="form.loanAmount" type="number" min="0"
|
||||
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>연이자율(%)
|
||||
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
|
||||
placeholder="예: 5.25" :disabled="submitting" />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">상환방식</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="m in LOAN_METHODS" :key="m.value"
|
||||
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
|
||||
:disabled="submitting" @click="form.loanMethod = m.value"
|
||||
>{{ m.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>대출기간(개월)
|
||||
<input v-model.number="form.loanMonths" type="number" min="1"
|
||||
placeholder="예: 120 (10년)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>대출시작일
|
||||
<input v-model="form.loanStart" type="date" :disabled="submitting" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -603,42 +296,46 @@ button.primary {
|
||||
.nw-card .value.debt {
|
||||
color: #c0392b;
|
||||
}
|
||||
.tabbar {
|
||||
.wallet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; /* 한 줄 유지(넘치면 가로 스크롤), 스크롤바 숨김 */
|
||||
}
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.tab-add {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tabs button {
|
||||
.text-btn {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 0.7rem;
|
||||
white-space: nowrap; /* 버튼 안 글자가 줄바꿈되지 않도록(두 줄 방지) */
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text);
|
||||
opacity: 0.75;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
.reorder-btn.active {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
}
|
||||
.reorder-hint {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.wallet-group {
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
.wg-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 0.35rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.wallet-list {
|
||||
list-style: none;
|
||||
@@ -653,10 +350,6 @@ button.primary {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wallet-row:hover {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
@@ -810,6 +503,13 @@ button.primary {
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.name.link {
|
||||
cursor: pointer;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.name.link:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -905,6 +605,10 @@ button.primary {
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.msg.wallet-limit {
|
||||
color: #b8860b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wallet-form {
|
||||
display: flex;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
import BottomSheet from '@/components/ui/BottomSheet.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
@@ -17,20 +20,28 @@ const budgetsRaw = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 폼 — 라우트(?budget) 바인딩으로 뒤로가기 시 닫힘(페이지 전환처럼)
|
||||
const formOpen = ref(false)
|
||||
function openForm() {
|
||||
formOpen.value = true
|
||||
if (route.query.budget == null) router.push({ query: { ...route.query, budget: '1' } })
|
||||
}
|
||||
function closeForm() {
|
||||
formOpen.value = false
|
||||
if (route.query.budget != null) router.back()
|
||||
}
|
||||
watch(() => route.query.budget, (v) => { if (v == null) formOpen.value = false })
|
||||
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
// 분류 선택 바텀시트 — 최종 분류 정해지면 닫힘
|
||||
const catSheet = ref(false)
|
||||
watch(() => form.category, (v) => { if (v) catSheet.value = false })
|
||||
|
||||
// 분류(지출) 드롭다운
|
||||
const categories = ref([])
|
||||
@@ -46,28 +57,6 @@ const periodLabel = computed(() => `${year.value}년 ${String(month.value).padSt
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function daysInMonth(y, m) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
function daysInYear(y) {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
|
||||
}
|
||||
function baseDays(unit) {
|
||||
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
|
||||
}
|
||||
|
||||
// 고정 모드 자동 환산 미리보기 (현재 월/년 실제 일수 기준)
|
||||
const fixedPreview = computed(() => {
|
||||
if (!form.fixed) return null
|
||||
const amount = Number(form.baseAmount) || 0
|
||||
const daily = amount / baseDays(form.baseUnit)
|
||||
return {
|
||||
weekly: Math.round(daily * 7),
|
||||
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
|
||||
yearly: Math.round(daily * daysInYear(year.value)),
|
||||
}
|
||||
})
|
||||
|
||||
function ratio(s) {
|
||||
if (!s.monthlyBudget) return 0
|
||||
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
|
||||
@@ -178,35 +167,17 @@ async function copyFromPrevMonth() {
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
Object.assign(form, { category: '', monthly: null })
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
openForm()
|
||||
}
|
||||
function openEdit(s) {
|
||||
const b = budgetsRaw.value.find((x) => x.id === s.id)
|
||||
if (!b) return
|
||||
editId.value = b.id
|
||||
Object.assign(form, {
|
||||
category: b.category,
|
||||
fixed: b.fixed,
|
||||
baseUnit: b.baseUnit || 'DAY',
|
||||
baseAmount: b.baseAmount,
|
||||
daily: b.daily,
|
||||
weekly: b.weekly,
|
||||
monthly: b.monthly,
|
||||
yearly: b.yearly,
|
||||
})
|
||||
Object.assign(form, { category: b.category, monthly: b.monthly })
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
openForm()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -215,28 +186,23 @@ async function submit() {
|
||||
formError.value = '카테고리를 입력하세요.'
|
||||
return
|
||||
}
|
||||
let payload
|
||||
if (form.fixed) {
|
||||
if (!form.baseAmount || form.baseAmount <= 0) {
|
||||
formError.value = '기준 금액을 입력하세요.'
|
||||
if (!form.monthly || form.monthly <= 0) {
|
||||
formError.value = '월 예산 금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
|
||||
} else {
|
||||
payload = {
|
||||
const payload = {
|
||||
category: form.category.trim(),
|
||||
fixed: false,
|
||||
daily: form.daily != null ? Number(form.daily) : null,
|
||||
weekly: form.weekly != null ? Number(form.weekly) : null,
|
||||
monthly: form.monthly != null ? Number(form.monthly) : null,
|
||||
yearly: form.yearly != null ? Number(form.yearly) : null,
|
||||
}
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: Number(form.monthly),
|
||||
yearly: null,
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editId.value) await accountApi.updateBudget(editId.value, payload)
|
||||
else await accountApi.createBudget(payload, { year: year.value, month: month.value })
|
||||
formOpen.value = false
|
||||
closeForm()
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
@@ -255,102 +221,16 @@ async function remove(s) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 1회성 예산 =====
|
||||
const onetimes = ref([])
|
||||
const onetimeFormOpen = ref(false)
|
||||
const onetimeEditId = ref(null)
|
||||
const onetimeForm = reactive({ title: '', category: '', amount: null, startDate: '', endDate: '' })
|
||||
const onetimeSubmitting = ref(false)
|
||||
const onetimeFormError = ref(null)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadOnetime() {
|
||||
try { onetimes.value = await accountApi.onetimeBudgets() }
|
||||
catch { onetimes.value = [] }
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return s === 'UPCOMING' ? '예정' : s === 'ACTIVE' ? '진행' : '종료'
|
||||
}
|
||||
function onetimeRatio(o) {
|
||||
if (!o.amount) return 0
|
||||
return Math.min(Math.round((o.spent / o.amount) * 100), 999)
|
||||
}
|
||||
function onetimeBarClass(o) {
|
||||
const r = o.amount ? o.spent / o.amount : 0
|
||||
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
|
||||
}
|
||||
|
||||
function openOnetimeCreate() {
|
||||
onetimeEditId.value = null
|
||||
Object.assign(onetimeForm, { title: '', category: '', amount: null, startDate: todayStr(), endDate: '' })
|
||||
onetimeFormError.value = null
|
||||
onetimeFormOpen.value = true
|
||||
}
|
||||
function openOnetimeEdit(o) {
|
||||
onetimeEditId.value = o.id
|
||||
Object.assign(onetimeForm, {
|
||||
title: o.title,
|
||||
category: o.category || '',
|
||||
amount: o.amount,
|
||||
startDate: o.startDate,
|
||||
endDate: o.endDate,
|
||||
})
|
||||
onetimeFormError.value = null
|
||||
onetimeFormOpen.value = true
|
||||
}
|
||||
|
||||
async function submitOnetime() {
|
||||
onetimeFormError.value = null
|
||||
if (!onetimeForm.title.trim()) { onetimeFormError.value = '제목을 입력하세요.'; return }
|
||||
if (!onetimeForm.amount || onetimeForm.amount <= 0) { onetimeFormError.value = '금액을 입력하세요.'; return }
|
||||
if (!onetimeForm.startDate) { onetimeFormError.value = '시작일을 입력하세요.'; return }
|
||||
if (!onetimeForm.endDate) { onetimeFormError.value = '종료일을 입력하세요.'; return }
|
||||
if (onetimeForm.startDate > onetimeForm.endDate) { onetimeFormError.value = '종료일이 시작일보다 빠를 수 없습니다.'; return }
|
||||
const payload = {
|
||||
title: onetimeForm.title.trim(),
|
||||
category: onetimeForm.category || null,
|
||||
amount: Number(onetimeForm.amount),
|
||||
startDate: onetimeForm.startDate,
|
||||
endDate: onetimeForm.endDate,
|
||||
}
|
||||
onetimeSubmitting.value = true
|
||||
try {
|
||||
if (onetimeEditId.value) await accountApi.updateOnetimeBudget(onetimeEditId.value, payload)
|
||||
else await accountApi.createOnetimeBudget(payload)
|
||||
onetimeFormOpen.value = false
|
||||
await loadOnetime()
|
||||
} catch (e) {
|
||||
onetimeFormError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
onetimeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOnetime(o) {
|
||||
if (!(await dialog.confirm(`'${o.title}' 1회성 예산을 삭제할까요?`, { title: '삭제', danger: true }))) return
|
||||
try {
|
||||
await accountApi.removeOnetimeBudget(o.id)
|
||||
await loadOnetime()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadCategories()
|
||||
loadIncome()
|
||||
loadOnetime()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="budget">
|
||||
<template v-if="!formOpen">
|
||||
<div class="month-nav">
|
||||
<span class="mn-spacer"></span>
|
||||
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
||||
@@ -417,77 +297,25 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가"로 시작하세요.</p>
|
||||
</template>
|
||||
|
||||
<!-- 1회성 예산 섹션 -->
|
||||
<div class="onetime-section">
|
||||
<div class="onetime-header">
|
||||
<span class="onetime-title">1회성 예산</span>
|
||||
<IconBtn icon="plus" title="1회성 예산 추가" variant="primary" size="sm" @click="openOnetimeCreate" />
|
||||
</div>
|
||||
|
||||
<ul v-if="onetimes.length" class="onetime-list">
|
||||
<li v-for="o in onetimes" :key="o.id" class="onetime-row">
|
||||
<div class="ot-top">
|
||||
<span class="ot-name">{{ o.title }}</span>
|
||||
<span class="ot-status" :class="o.status.toLowerCase()">{{ statusLabel(o.status) }}</span>
|
||||
<span class="ot-amounts">
|
||||
<span :class="o.remaining < 0 ? 'neg' : ''">{{ won(o.spent) }}</span>
|
||||
<span class="ot-sep">/</span>
|
||||
{{ won(o.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" :class="onetimeBarClass(o)" :style="{ width: Math.min(onetimeRatio(o), 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="ot-bottom">
|
||||
<span class="ot-date">{{ o.startDate }} ~ {{ o.endDate }}</span>
|
||||
<span v-if="o.category" class="ot-cat">{{ o.category }}</span>
|
||||
<span class="ot-remain" :class="o.remaining < 0 ? 'neg' : ''">
|
||||
{{ o.remaining >= 0 ? `잔여 ${won(o.remaining)}` : `초과 ${won(-o.remaining)}` }}
|
||||
</span>
|
||||
<span class="acts">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openOnetimeEdit(o)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeOnetime(o)" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="msg ot-empty">등록된 1회성 예산이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 1회성 예산 추가/수정 모달 -->
|
||||
<AppModal v-model="onetimeFormOpen" :title="`1회성 예산 ${onetimeEditId ? '수정' : '추가'}`">
|
||||
<form class="budget-form" @submit.prevent="submitOnetime">
|
||||
<label>제목<input v-model="onetimeForm.title" type="text" placeholder="예: 7월 여행, 추석 준비" :disabled="onetimeSubmitting" /></label>
|
||||
<div class="field">
|
||||
<span class="field-label">카테고리 <span class="field-hint">(선택 — 비우면 전체 지출)</span></span>
|
||||
<CategoryPicker
|
||||
v-model="onetimeForm.category"
|
||||
type="EXPENSE"
|
||||
:categories="categories"
|
||||
:disabled="onetimeSubmitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</div>
|
||||
<label>예산 금액<input v-model.number="onetimeForm.amount" type="number" min="1" placeholder="원" :disabled="onetimeSubmitting" /></label>
|
||||
<div class="date-range">
|
||||
<label>시작일<input v-model="onetimeForm.startDate" type="date" :disabled="onetimeSubmitting" /></label>
|
||||
<span class="range-sep">~</span>
|
||||
<label>종료일<input v-model="onetimeForm.endDate" type="date" :disabled="onetimeSubmitting" /></label>
|
||||
</div>
|
||||
<p v-if="onetimeFormError" class="msg error">{{ onetimeFormError }}</p>
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="onetimeFormOpen = false" />
|
||||
<IconBtn icon="save" :title="onetimeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="onetimeSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 추가/수정 모달 (월별) -->
|
||||
<AppModal v-model="formOpen" :title="`예산 ${editId ? '수정' : '추가'}`">
|
||||
<!-- 예산 추가/수정 (인라인 페이지 — 헤더·하단바 유지, 뒤로가기로 닫힘) -->
|
||||
<div v-else class="budget-page">
|
||||
<header class="ep-head">
|
||||
<span class="ep-title">예산 {{ editId ? '수정' : '추가' }}</span>
|
||||
</header>
|
||||
<form class="budget-form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<span class="field-label">카테고리</span>
|
||||
<button
|
||||
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
|
||||
:disabled="submitting" @click="catSheet = true"
|
||||
>
|
||||
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
|
||||
<span class="st-caret">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
<BottomSheet v-model="catSheet" title="분류 선택">
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
type="EXPENSE"
|
||||
@@ -495,49 +323,19 @@ onMounted(() => {
|
||||
:disabled="submitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">예산 방식</span>
|
||||
<div class="chip-group">
|
||||
<button type="button" class="chip" :class="{ active: !form.fixed }" :disabled="submitting" @click="form.fixed = false">비고정 (기간별 직접)</button>
|
||||
<button type="button" class="chip" :class="{ active: form.fixed }" :disabled="submitting" @click="form.fixed = true">고정 (자동 환산)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 고정 -->
|
||||
<template v-if="form.fixed">
|
||||
<div class="fixed-base">
|
||||
<select v-model="form.baseUnit" :disabled="submitting">
|
||||
<option value="DAY">일</option>
|
||||
<option value="WEEK">주</option>
|
||||
<option value="MONTH">월</option>
|
||||
</select>
|
||||
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
|
||||
</div>
|
||||
<p v-if="fixedPreview" class="preview">
|
||||
환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
|
||||
<span class="note">(이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 비고정 -->
|
||||
<template v-else>
|
||||
<label>일 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>주 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>년 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
||||
</template>
|
||||
<p class="note">설정한 월 예산 기준으로 이번 달 지출과 비교됩니다.</p>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="close" title="취소" @click="closeForm()" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -546,6 +344,52 @@ onMounted(() => {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.budget-page .budget-form {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ep-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.ep-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sheet-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sheet-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sheet-trigger.empty .st-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.st-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-caret {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
import AccountPicker from '@/components/ui/AccountPicker.vue'
|
||||
import BottomSheet from '@/components/ui/BottomSheet.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const editId = route.params.id ? Number(route.params.id) : null
|
||||
const isEdit = editId != null
|
||||
|
||||
const wallets = ref([])
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
const catSheet = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
type: 'EXPENSE',
|
||||
amount: null,
|
||||
category: '',
|
||||
memo: '',
|
||||
walletKind: '',
|
||||
walletId: '',
|
||||
toWalletKind: '',
|
||||
toWalletId: '',
|
||||
frequency: 'MONTHLY',
|
||||
dayOfMonth: 1,
|
||||
dayOfWeek: 1,
|
||||
monthOfYear: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
active: true,
|
||||
})
|
||||
// 분류가 최종 선택(비어있지 않은 값)되면 시트 닫기
|
||||
watch(() => form.category, (v) => { if (v) catSheet.value = false })
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
|
||||
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
|
||||
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
|
||||
]
|
||||
const FREQS = [
|
||||
{ value: 'MONTHLY', label: '매월' },
|
||||
{ value: 'YEARLY', label: '매년' },
|
||||
]
|
||||
const WALLET_KINDS = [
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'MINUS', label: '마이너스' },
|
||||
]
|
||||
const fromWalletKinds = computed(() =>
|
||||
WALLET_KINDS.filter((k) => k.value !== 'CARD' || form.type === 'EXPENSE'),
|
||||
)
|
||||
const toWalletKinds = computed(() => WALLET_KINDS.filter((k) => k.value !== 'CARD'))
|
||||
// 구분에 따라 선택 가능한 출금/입금 계좌 목록(계좌 종류로 필터)
|
||||
const fromWallets = computed(() =>
|
||||
wallets.value.filter((w) => fromWalletKinds.value.some((k) => k.value === w.type)),
|
||||
)
|
||||
const toWallets = computed(() =>
|
||||
wallets.value.filter((w) => toWalletKinds.value.some((k) => k.value === w.type)),
|
||||
)
|
||||
function onTypeChange() {
|
||||
form.category = ''
|
||||
// 선택된 출금 계좌가 새 구분에서 허용되지 않으면 해제
|
||||
const fw = wallets.value.find((w) => w.id === form.walletId)
|
||||
if (fw && !fromWalletKinds.value.some((k) => k.value === fw.type)) form.walletId = ''
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ws, recs] = await Promise.all([
|
||||
accountApi.wallets(),
|
||||
isEdit ? accountApi.recurrings() : Promise.resolve([]),
|
||||
])
|
||||
wallets.value = ws
|
||||
await loadCategories()
|
||||
if (isEdit) {
|
||||
const r = recs.find((x) => x.id === editId)
|
||||
if (!r) {
|
||||
formError.value = '정기결제를 찾을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
Object.assign(form, {
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
amount: r.amount,
|
||||
category: r.category || '',
|
||||
memo: r.memo || '',
|
||||
walletId: r.walletId || '',
|
||||
toWalletId: r.toWalletId || '',
|
||||
frequency: r.frequency,
|
||||
dayOfMonth: r.dayOfMonth || 1,
|
||||
dayOfWeek: r.dayOfWeek || 1,
|
||||
monthOfYear: r.monthOfYear || 1,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate || '',
|
||||
active: r.active,
|
||||
})
|
||||
} else {
|
||||
form.startDate = todayStr()
|
||||
}
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.replace('/account/recurrings')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.title.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
formError.value = '금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
|
||||
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.startDate) {
|
||||
formError.value = '시작일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
type: form.type,
|
||||
amount: Number(form.amount),
|
||||
category: form.type === 'TRANSFER' ? null : form.category || null,
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
frequency: form.frequency,
|
||||
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
|
||||
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
|
||||
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
active: form.active,
|
||||
}
|
||||
try {
|
||||
if (isEdit) await accountApi.updateRecurring(editId, payload)
|
||||
else await accountApi.createRecurring(payload)
|
||||
router.replace('/account/recurrings')
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rform">
|
||||
<header class="rf-head">
|
||||
<span class="rf-title">정기결제 {{ isEdit ? '수정' : '추가' }}</span>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<form v-else class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 구분 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">구분</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="t in TYPES" :key="t.value"
|
||||
type="button" class="chip" :class="[t.cls, { active: form.type === t.value }]"
|
||||
:disabled="submitting"
|
||||
@click="form.type = t.value; onTypeChange()"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 출금 계좌 (종류→계좌 아코디언) -->
|
||||
<div class="field">
|
||||
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
|
||||
<AccountPicker
|
||||
v-model="form.walletId"
|
||||
:wallets="fromWallets"
|
||||
:disabled="submitting"
|
||||
title="계좌 선택" placeholder="계좌를 선택하세요"
|
||||
empty-text="등록된 계좌가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 입금 계좌 (이체일 때만) -->
|
||||
<div v-if="form.type === 'TRANSFER'" class="field">
|
||||
<span class="field-label">입금 계좌</span>
|
||||
<AccountPicker
|
||||
v-model="form.toWalletId"
|
||||
:wallets="toWallets"
|
||||
:disabled="submitting"
|
||||
title="입금 계좌 선택" placeholder="계좌를 선택하세요"
|
||||
empty-text="등록된 계좌가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 분류 — 트리거 → 바텀시트(CategoryPicker) (이체 제외) -->
|
||||
<div v-if="form.type !== 'TRANSFER'" class="field">
|
||||
<span class="field-label">분류</span>
|
||||
<button
|
||||
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
|
||||
:disabled="submitting" @click="catSheet = true"
|
||||
>
|
||||
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
|
||||
<span class="st-caret">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
<BottomSheet v-model="catSheet" title="분류 선택">
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
:type="form.type"
|
||||
:categories="categories"
|
||||
:disabled="submitting"
|
||||
@category-added="loadCategories()"
|
||||
/>
|
||||
</BottomSheet>
|
||||
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 주기 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">주기</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="f in FREQS" :key="f.value"
|
||||
type="button" class="chip" :class="{ active: form.frequency === f.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.frequency = f.value"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="goBack" />
|
||||
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rform {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.rf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.rf-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.recur-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.recur-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.recur-form label.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.recur-form input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.recur-form label.row input {
|
||||
padding: 0;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.type-chips,
|
||||
.wallet-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.chip-expense.active {
|
||||
border-color: #c0392b;
|
||||
background: #c0392b;
|
||||
}
|
||||
.chip-income.active {
|
||||
border-color: #2e7d32;
|
||||
background: #2e7d32;
|
||||
}
|
||||
.chip-transfer.active {
|
||||
border-color: #1565c0;
|
||||
background: #1565c0;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
/* 분류 시트 트리거(필드형) */
|
||||
.sheet-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sheet-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sheet-trigger.empty .st-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.st-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-caret {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const recurrings = ref([])
|
||||
const wallets = ref([])
|
||||
const categories = ref([])
|
||||
|
||||
// 무료 회원 정기결제 등록 한도(써보고 → 업셀). 유료는 무제한. 백엔드에서도 강제.
|
||||
const FREE_RECURRING_LIMIT = 2
|
||||
const atRecurringLimit = computed(
|
||||
() => !auth.isPremium && recurrings.value.length >= FREE_RECURRING_LIMIT,
|
||||
)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
type: 'EXPENSE',
|
||||
amount: null,
|
||||
category: '',
|
||||
memo: '',
|
||||
walletKind: '',
|
||||
walletId: '',
|
||||
toWalletKind: '',
|
||||
toWalletId: '',
|
||||
frequency: 'MONTHLY',
|
||||
dayOfMonth: 1,
|
||||
dayOfWeek: 1,
|
||||
monthOfYear: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
active: true,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
|
||||
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
|
||||
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
|
||||
]
|
||||
const FREQS = [
|
||||
{ value: 'DAILY', label: '매일' },
|
||||
{ value: 'WEEKLY', label: '매주' },
|
||||
{ value: 'MONTHLY', label: '매월' },
|
||||
{ value: 'YEARLY', label: '매년' },
|
||||
]
|
||||
|
||||
// 계좌/카드 종류
|
||||
const WALLET_KINDS = [
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'MINUS', label: '마이너스' },
|
||||
]
|
||||
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
||||
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
||||
function walletKindOf(id) {
|
||||
const w = wallets.value.find((x) => x.id === id)
|
||||
return w ? w.type : ''
|
||||
}
|
||||
function onWalletKindChange() { form.walletId = '' }
|
||||
function onToWalletKindChange() { form.toWalletId = '' }
|
||||
function onTypeChange() { form.category = '' }
|
||||
|
||||
function won(n) { return (n ?? 0).toLocaleString('ko-KR') }
|
||||
function typeLabel(t) {
|
||||
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
|
||||
}
|
||||
function freqLabel(r) {
|
||||
if (r.frequency === 'DAILY') return '매일'
|
||||
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
|
||||
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}일`
|
||||
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
|
||||
}
|
||||
|
||||
function payKey(r) {
|
||||
if (r.frequency === 'DAILY') return 0
|
||||
if (r.frequency === 'WEEKLY') return r.dayOfWeek || 0
|
||||
if (r.frequency === 'YEARLY') return (r.monthOfYear || 0) * 100 + (r.dayOfMonth || 0)
|
||||
return r.dayOfMonth || 0
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
{ key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] },
|
||||
{ key: 'MONTH', label: '월간 거래', freqs: ['MONTHLY'] },
|
||||
{ key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
|
||||
]
|
||||
const grouped = computed(() =>
|
||||
@@ -109,23 +57,11 @@ const grouped = computed(() =>
|
||||
}).filter((g) => g.count > 0),
|
||||
)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [recs, ws, cats] = await Promise.all([
|
||||
accountApi.recurrings(),
|
||||
accountApi.wallets(),
|
||||
accountApi.categories(),
|
||||
])
|
||||
recurrings.value = recs
|
||||
wallets.value = ws
|
||||
categories.value = cats
|
||||
recurrings.value = await accountApi.recurrings()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
@@ -136,78 +72,30 @@ async function load() {
|
||||
async function runNow() {
|
||||
try {
|
||||
const res = await accountApi.runRecurrings()
|
||||
alert(`${res.generated}건의 고정 지출가 반영되었습니다.`)
|
||||
alert(`${res.generated}건의 정기결제가 반영되었습니다.`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '반영에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
|
||||
walletKind: '', walletId: '', toWalletKind: '', toWalletId: '',
|
||||
frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
|
||||
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(r) {
|
||||
editId.value = r.id
|
||||
Object.assign(form, {
|
||||
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
|
||||
walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
|
||||
toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '',
|
||||
frequency: r.frequency,
|
||||
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
|
||||
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.title.trim()) { formError.value = '이름을 입력하세요.'; return }
|
||||
if (!form.amount || form.amount <= 0) { formError.value = '금액을 입력하세요.'; return }
|
||||
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
|
||||
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
|
||||
async function openCreate() {
|
||||
if (atRecurringLimit.value) {
|
||||
const go = await dialog.confirm(
|
||||
`무료 회원은 정기결제를 ${FREE_RECURRING_LIMIT}개까지 등록할 수 있어요.\n유료로 업그레이드하면 무제한이에요.`,
|
||||
{ title: '등록 한도', okText: '업그레이드', cancelText: '닫기' },
|
||||
)
|
||||
if (go) router.push('/upgrade')
|
||||
return
|
||||
}
|
||||
if (!form.startDate) { formError.value = '시작일을 입력하세요.'; return }
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
type: form.type,
|
||||
amount: Number(form.amount),
|
||||
category: form.type === 'TRANSFER' ? null : form.category || null,
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
frequency: form.frequency,
|
||||
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
|
||||
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
|
||||
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
active: form.active,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateRecurring(editId.value, payload)
|
||||
else await accountApi.createRecurring(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
router.push('/account/recurrings/new')
|
||||
}
|
||||
function openEdit(r) {
|
||||
router.push(`/account/recurrings/${r.id}/edit`)
|
||||
}
|
||||
|
||||
async function remove(r) {
|
||||
if (!(await dialog.confirm(`'${r.title}' 고정 지출를 삭제할까요?\n이미 생성된 내역은 유지됩니다.`, { title: '고정지출 삭제', danger: true }))) return
|
||||
if (!(await dialog.confirm(`'${r.title}' 정기결제를 삭제할까요?\n이미 생성된 내역은 유지됩니다.`, { title: '정기결제 삭제', danger: true }))) return
|
||||
try {
|
||||
await accountApi.removeRecurring(r.id)
|
||||
await load()
|
||||
@@ -224,8 +112,12 @@ onMounted(load)
|
||||
<p class="hint">등록한 주기에 맞춰 가계부 진입 시 자동으로 <b>'확인 필요'</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 뒤 <b>'확인'</b>을 눌러 확정하세요.</p>
|
||||
<div class="recur-actions">
|
||||
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
||||
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
|
||||
<IconBtn icon="plus" title="정기결제 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
<p v-if="atRecurringLimit" class="msg recur-limit">
|
||||
무료 {{ FREE_RECURRING_LIMIT }}개까지 등록 완료 ·
|
||||
<RouterLink to="/upgrade" class="recur-upsell">유료 무제한 →</RouterLink>
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
@@ -261,117 +153,7 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else-if="!loading" class="msg">등록된 고정 지출가 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<AppModal v-model="formOpen" :title="`고정 지출 ${editId ? '수정' : '추가'}`">
|
||||
<form class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 구분 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">구분</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="t in TYPES" :key="t.value"
|
||||
type="button" class="chip" :class="[t.cls, { active: form.type === t.value }]"
|
||||
:disabled="submitting"
|
||||
@click="form.type = t.value; onTypeChange()"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 출금 계좌 종류 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
|
||||
<div class="wallet-chips">
|
||||
<button
|
||||
v-for="k in WALLET_KINDS" :key="k.value"
|
||||
type="button" class="chip" :class="{ active: form.walletKind === k.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.walletKind = k.value; onWalletKindChange()"
|
||||
>{{ k.label }}</button>
|
||||
</div>
|
||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 입금 계좌 종류 배지 (이체일 때만) -->
|
||||
<div v-if="form.type === 'TRANSFER'" class="field">
|
||||
<span class="field-label">입금 계좌</span>
|
||||
<div class="wallet-chips">
|
||||
<button
|
||||
v-for="k in WALLET_KINDS" :key="k.value"
|
||||
type="button" class="chip" :class="{ active: form.toWalletKind === k.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.toWalletKind = k.value; onToWalletKindChange()"
|
||||
>{{ k.label }}</button>
|
||||
</div>
|
||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 분류 — CategoryPicker (이체 제외) -->
|
||||
<div v-if="form.type !== 'TRANSFER'" class="field">
|
||||
<span class="field-label">분류</span>
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
:type="form.type"
|
||||
:categories="categories"
|
||||
:disabled="submitting"
|
||||
@category-added="load()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 주기 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">주기</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="f in FREQS" :key="f.value"
|
||||
type="button" class="chip" :class="{ active: form.frequency === f.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.frequency = f.value"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-if="form.frequency === 'WEEKLY'">요일
|
||||
<select v-model.number="form.dayOfWeek" :disabled="submitting">
|
||||
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
<p v-else-if="!loading" class="msg">등록된 정기결제가 없습니다.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -454,67 +236,6 @@ button.primary { border-color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 3
|
||||
.actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; }
|
||||
.msg { margin: 0.75rem 0; }
|
||||
.msg.error { color: #c0392b; }
|
||||
|
||||
/* 폼 */
|
||||
.recur-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.recur-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.recur-form label.row { flex-direction: row; align-items: center; gap: 0.4rem; }
|
||||
.recur-form input,
|
||||
.recur-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.recur-form label.row input { padding: 0; }
|
||||
|
||||
/* 배지형 선택 */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-label { font-size: 0.82rem; opacity: 0.7; }
|
||||
.type-chips,
|
||||
.wallet-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
/* 기본 active (계좌/주기) */
|
||||
.chip.active { border-color: hsla(160, 100%, 37%, 1); background: hsla(160, 100%, 37%, 1); color: #fff; }
|
||||
/* 구분 배지 type별 색상 */
|
||||
.chip-expense.active { border-color: #c0392b; background: #c0392b; }
|
||||
.chip-income.active { border-color: #2e7d32; background: #2e7d32; }
|
||||
.chip-transfer.active { border-color: #1565c0; background: #1565c0; }
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.msg.recur-limit { color: #b8860b; font-size: 0.85rem; }
|
||||
.recur-upsell { color: hsla(160, 100%, 37%, 1); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const items = ref([]) // { id, kind, name, sub, rows, partial }
|
||||
const expanded = ref(new Set())
|
||||
|
||||
const now = currentYm()
|
||||
const nowLabel = `${now.y}.${String(now.m).padStart(2, '0')}`
|
||||
|
||||
function won(n) {
|
||||
return n == null ? '-' : Number(n).toLocaleString('ko-KR')
|
||||
}
|
||||
function loanMethodLabel(v) {
|
||||
return v === 'EQUAL_PAYMENT'
|
||||
? '원리금균등'
|
||||
: v === 'EQUAL_PRINCIPAL'
|
||||
? '원금균등'
|
||||
: v === 'BULLET'
|
||||
? '만기일시'
|
||||
: '상환'
|
||||
}
|
||||
function loanSub(w) {
|
||||
const parts = [loanMethodLabel(w.loanMethod)]
|
||||
if (w.loanRate) parts.push(`${w.loanRate}%`)
|
||||
if (w.loanMonths) parts.push(`${w.loanMonths}개월`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const ws = await accountApi.wallets()
|
||||
const list = []
|
||||
|
||||
// 대출: 금리가 설정된 대출만 상각 스케줄 계산
|
||||
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
|
||||
const { rows, partial } = loanSchedule(w, now)
|
||||
if (!rows.length) continue
|
||||
list.push({ id: `loan-${w.id}`, kind: 'LOAN', name: w.name, sub: loanSub(w), rows, partial })
|
||||
}
|
||||
|
||||
// 카드 할부(무이자): 진행 중인 할부 건별로
|
||||
const cards = ws.filter((x) => x.type === 'CARD')
|
||||
const entryLists = await Promise.all(
|
||||
cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])),
|
||||
)
|
||||
cards.forEach((c, i) => {
|
||||
const purchases = (entryLists[i] || []).filter(
|
||||
(e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2,
|
||||
)
|
||||
for (const e of purchases) {
|
||||
const sch = cardInstallmentSchedule(e, now)
|
||||
if (!sch) continue
|
||||
const desc = e.category || e.memo || '할부'
|
||||
list.push({
|
||||
id: `card-${e.id}`,
|
||||
kind: 'CARD',
|
||||
name: c.name,
|
||||
sub: `${desc} · ${e.installmentMonths}개월 무이자 · ${String(e.entryDate || '').slice(0, 10)}`,
|
||||
rows: sch.rows,
|
||||
partial: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
items.value = list
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
// 이번 달 상환 합계 (이번 달 회차만)
|
||||
const thisMonth = computed(() => {
|
||||
let p = 0, i = 0, t = 0
|
||||
for (const it of items.value) {
|
||||
for (const r of it.rows) {
|
||||
if (r.label !== nowLabel) continue
|
||||
p += r.principal || 0
|
||||
i += r.interest || 0
|
||||
t += r.total ?? (r.principal || 0) + (r.interest || 0)
|
||||
}
|
||||
}
|
||||
return { p, i, t }
|
||||
})
|
||||
// 남은 상환 총액
|
||||
const remainTotal = computed(() => {
|
||||
let p = 0, i = 0, t = 0
|
||||
for (const it of items.value) {
|
||||
for (const r of it.rows) {
|
||||
p += r.principal || 0
|
||||
i += r.interest || 0
|
||||
t += r.total ?? (r.principal || 0) + (r.interest || 0)
|
||||
}
|
||||
}
|
||||
return { p, i, t }
|
||||
})
|
||||
|
||||
function itemThisMonth(it) {
|
||||
const r = it.rows.find((x) => x.label === nowLabel)
|
||||
return r ? (r.total ?? (r.principal || 0) + (r.interest || 0)) : null
|
||||
}
|
||||
function itemRemain(it) {
|
||||
return it.rows.reduce((s, r) => s + (r.total ?? (r.principal || 0) + (r.interest || 0)), 0)
|
||||
}
|
||||
function toggle(id) {
|
||||
const s = new Set(expanded.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
expanded.value = s
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="repay">
|
||||
<h1 class="title">대출·할부 상환</h1>
|
||||
<p class="lead">대출 월 상환금과 카드 할부금을 원금·이자로 나눠, 남은 회차까지 한눈에 봅니다.</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 합계 요약 -->
|
||||
<div class="summary">
|
||||
<div class="sum-card accent">
|
||||
<span class="k">이번 달 상환 예정금액</span>
|
||||
<span class="v">{{ won(thisMonth.t) }}</span>
|
||||
<span class="split">원금 {{ won(thisMonth.p) }} · 이자 {{ won(thisMonth.i) }}</span>
|
||||
</div>
|
||||
<div class="sum-card">
|
||||
<span class="k">남은 상환 총액</span>
|
||||
<span class="v">{{ won(remainTotal.t) }}</span>
|
||||
<span class="split">원금 {{ won(remainTotal.p) }} · 이자 {{ won(remainTotal.i) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length" class="empty-state">
|
||||
<p class="empty-emoji">💳</p>
|
||||
<p class="empty-title">상환 중인 대출·할부가 없어요</p>
|
||||
<p class="empty-desc">대출 계좌(금리 입력)나 카드 할부 결제가 있으면 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 항목별 -->
|
||||
<div v-for="it in items" :key="it.id" class="item">
|
||||
<button type="button" class="item-head" @click="toggle(it.id)">
|
||||
<span class="kind" :class="it.kind === 'LOAN' ? 'k-loan' : 'k-card'">
|
||||
{{ it.kind === 'LOAN' ? '대출' : '카드' }}
|
||||
</span>
|
||||
<span class="info">
|
||||
<span class="name">{{ it.name }}</span>
|
||||
<span class="sub">{{ it.sub }}</span>
|
||||
</span>
|
||||
<span class="amt">
|
||||
<span class="amt-now">{{ won(itemThisMonth(it)) }}</span>
|
||||
<span class="amt-rem">남은 회차 {{ it.rows.length }}회 · {{ won(itemRemain(it)) }}</span>
|
||||
</span>
|
||||
<span class="chev">{{ expanded.has(it.id) ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded.has(it.id)" class="sched-wrap">
|
||||
<p v-if="it.partial" class="partial-note">상환 개월(기간) 정보가 없어 이번 달 이자만 표시합니다. 계좌 관리에서 대출 개월수를 입력하면 전체 스케줄이 계산됩니다.</p>
|
||||
<table class="sched">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회차</th>
|
||||
<th class="num">원금</th>
|
||||
<th class="num">이자</th>
|
||||
<th class="num">합계</th>
|
||||
<th class="num">남은 잔액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, idx) in it.rows" :key="idx" :class="{ current: r.label === nowLabel }">
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="num">{{ won(r.principal) }}</td>
|
||||
<td class="num">{{ won(r.interest) }}</td>
|
||||
<td class="num strong">{{ won(r.total) }}</td>
|
||||
<td class="num muted">{{ won(r.balance) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.repay {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.lead {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.sum-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.sum-card.accent {
|
||||
border-color: hsla(160, 100%, 37%, 0.6);
|
||||
background: hsla(160, 100%, 37%, 0.06);
|
||||
}
|
||||
.sum-card .k {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sum-card .v {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.sum-card .split {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.7rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.item-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
}
|
||||
.kind {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.k-loan {
|
||||
background: hsla(215, 90%, 55%, 0.15);
|
||||
color: hsla(215, 90%, 55%, 1);
|
||||
}
|
||||
.k-card {
|
||||
background: hsla(280, 60%, 55%, 0.15);
|
||||
color: hsla(280, 60%, 60%, 1);
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.info .name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info .sub {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.amt {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.amt-now {
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.amt-rem {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chev {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.sched-wrap {
|
||||
padding: 0 0.5rem 0.6rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.partial-note {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
padding: 0.4rem 0.4rem 0.6rem;
|
||||
}
|
||||
.sched {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sched th,
|
||||
.sched td {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sched th {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.6;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sched .num {
|
||||
text-align: right;
|
||||
}
|
||||
.sched td.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.sched td.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sched tr.current td {
|
||||
background: hsla(160, 100%, 37%, 0.08);
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.empty-emoji {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-weight: 700;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.empty-desc {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const editId = route.params.id ? Number(route.params.id) : null
|
||||
const isEdit = editId != null
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'BANK', label: '은행계좌' },
|
||||
{ key: 'CASH', label: '현금' },
|
||||
{ key: 'CARD', label: '신용/체크카드' },
|
||||
{ key: 'LOAN', label: '대출' },
|
||||
{ key: 'MINUS', label: '마이너스통장' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
const CARD_TYPES = [
|
||||
{ value: 'CREDIT', label: '신용카드' },
|
||||
{ value: 'CHECK', label: '체크카드' },
|
||||
]
|
||||
const LOAN_METHODS = [
|
||||
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
|
||||
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
|
||||
{ value: 'BULLET', label: '만기일시' },
|
||||
{ value: '', label: '선택안함' },
|
||||
]
|
||||
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
|
||||
|
||||
const form = reactive({
|
||||
type: 'BANK',
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
loanAmount: null,
|
||||
loanRate: null,
|
||||
loanMethod: '',
|
||||
loanMonths: null,
|
||||
loanStart: '',
|
||||
loanPayment: null,
|
||||
})
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
const typeLabel = (t) => TYPES.find((x) => x.key === t)?.label || ''
|
||||
function issuerLabel(t) {
|
||||
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
}
|
||||
function openingLabel(t) {
|
||||
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
|
||||
if (t === 'CARD') return '초기 미결제 금액'
|
||||
if (t === 'INVEST') return '투자금(투입원금)'
|
||||
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
|
||||
return '기록 시작 시 잔액'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isEdit) return
|
||||
loading.value = true
|
||||
try {
|
||||
const ws = await accountApi.wallets()
|
||||
const w = ws.find((x) => x.id === editId)
|
||||
if (!w) {
|
||||
formError.value = '계좌를 찾을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
Object.assign(form, {
|
||||
type: w.type,
|
||||
name: w.name,
|
||||
issuer: w.issuer || '',
|
||||
accountNumber: w.accountNumber || '',
|
||||
cardType: w.cardType || 'CREDIT',
|
||||
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
|
||||
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
||||
openingDate: w.openingDate || '',
|
||||
currentValue: w.currentValue ?? null,
|
||||
loanAmount: w.loanAmount ?? null,
|
||||
loanRate: w.loanRate ?? null,
|
||||
loanMethod: w.loanMethod || '',
|
||||
loanMonths: w.loanMonths ?? null,
|
||||
loanStart: w.loanStart || '',
|
||||
loanPayment: w.loanPayment ?? null,
|
||||
})
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.replace('/account/wallets')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.name.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const magnitude = Number(form.openingBalance) || 0
|
||||
const isLoan = form.type === 'LOAN'
|
||||
const payload = {
|
||||
type: form.type,
|
||||
name: form.name.trim(),
|
||||
issuer: form.issuer || null,
|
||||
accountNumber: form.type === 'BANK' || form.type === 'MINUS' ? form.accountNumber || null : null,
|
||||
cardType: form.type === 'CARD' ? form.cardType : null,
|
||||
// 부채는 음수로 저장 (갚을 금액)
|
||||
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
|
||||
openingDate: form.openingDate || null,
|
||||
currentValue:
|
||||
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
||||
? Number(form.currentValue)
|
||||
: null,
|
||||
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
|
||||
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
|
||||
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
|
||||
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
|
||||
loanStart: isLoan && form.loanStart ? form.loanStart : null,
|
||||
loanPayment: isLoan && form.loanPayment ? Number(form.loanPayment) : null,
|
||||
}
|
||||
try {
|
||||
if (isEdit) await accountApi.updateWallet(editId, payload)
|
||||
else await accountApi.createWallet(payload)
|
||||
router.replace('/account/wallets')
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wform">
|
||||
<header class="wf-head">
|
||||
<span class="wf-title">{{ isEdit ? `${typeLabel(form.type)} 수정` : '계좌 추가' }}</span>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<form v-else class="wallet-form" @submit.prevent="submit">
|
||||
<!-- 종류 선택(생성 시에만) -->
|
||||
<div v-if="!isEdit" class="field">
|
||||
<span class="field-label">계좌 종류</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="t in TYPES" :key="t.key"
|
||||
type="button" class="chip" :class="{ active: form.type === t.key }"
|
||||
:disabled="submitting" @click="form.type = t.key"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<div v-if="form.type === 'CARD'" class="field">
|
||||
<span class="field-label">카드 종류</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="c in CARD_TYPES" :key="c.value"
|
||||
type="button" class="chip" :class="{ active: form.cardType === c.value }"
|
||||
:disabled="submitting" @click="form.cardType = c.value"
|
||||
>{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<template v-if="form.type === 'INVEST'">
|
||||
<label>현재 평가액
|
||||
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
<b>투자금</b>(투입원금)과 <b>현재 평가액</b>만 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
|
||||
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
|
||||
</p>
|
||||
</template>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 대출 전용 -->
|
||||
<template v-if="form.type === 'LOAN'">
|
||||
<label>대출 실행 금액(원금)
|
||||
<input v-model.number="form.loanAmount" type="number" min="0"
|
||||
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>연이자율(%)
|
||||
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.001"
|
||||
placeholder="예: 5.253" :disabled="submitting" />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">상환방식</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="m in LOAN_METHODS" :key="m.value"
|
||||
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
|
||||
:disabled="submitting" @click="form.loanMethod = m.value"
|
||||
>{{ m.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<label v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''">월 상환금(선택)
|
||||
<input v-model.number="form.loanPayment" type="number" min="0"
|
||||
placeholder="은행 조회 고정 월납입금 (예: 697868)" :disabled="submitting" />
|
||||
</label>
|
||||
<p v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''" class="form-hint">
|
||||
원리금균등은 <b>월 상환금</b>을 입력하면 상환표의 원금·이자가 은행 조회액과 정확히 일치합니다. (미입력 시 잔액·개월수로 추정)
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="goBack" />
|
||||
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wform {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.wf-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.wallet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.wallet-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.wallet-form input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.65;
|
||||
margin: -0.2rem 0 0.1rem;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,9 @@ const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
|
||||
function keyOf(it) {
|
||||
return `${it.targetType}:${it.targetId}`
|
||||
}
|
||||
function isSpam(it) {
|
||||
return it.blockReason === '스팸 키워드 감지'
|
||||
}
|
||||
function categoryLabel(c) {
|
||||
return CATEGORY_LABEL[c] || c || '게시판'
|
||||
}
|
||||
@@ -99,8 +102,8 @@ onMounted(load)
|
||||
<div class="report-admin">
|
||||
<div class="head">
|
||||
<p class="desc">
|
||||
신고 누적 <strong>5건</strong> 이상이면 자동 블라인드됩니다. 관리자는 여기서 신고 내용을 검토해
|
||||
<b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b>로 정리할 수 있습니다.
|
||||
신고 누적 <strong>5건</strong> 이상 또는 스팸 키워드 포함 시 자동 블라인드됩니다.
|
||||
관리자는 여기서 내용을 검토해 <b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b>로 정리할 수 있습니다.
|
||||
</p>
|
||||
<button class="reload" type="button" :disabled="loading" @click="load">↻ 새로고침</button>
|
||||
</div>
|
||||
@@ -123,7 +126,8 @@ onMounted(load)
|
||||
{{ it.targetType === 'POST' ? '글' : '댓글' }}
|
||||
</span>
|
||||
<span class="cat">{{ categoryLabel(it.category) }}</span>
|
||||
<span class="report-count">🚩 {{ it.reportCount }}</span>
|
||||
<span v-if="isSpam(it)" class="badge spam">🤖 스팸감지</span>
|
||||
<span v-else class="report-count">🚩 {{ it.reportCount }}</span>
|
||||
<span v-if="it.blocked" class="badge blind">블라인드됨</span>
|
||||
<span class="time">{{ formatTime(it.lastReportedAt) }}</span>
|
||||
</div>
|
||||
@@ -134,6 +138,7 @@ onMounted(load)
|
||||
</RouterLink>
|
||||
<p class="content">{{ it.content }}</p>
|
||||
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
|
||||
<p v-if="it.blockReason" class="block-reason">사유: {{ it.blockReason }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<RouterLink :to="linkTo(it)" class="btn ghost">글 보기</RouterLink>
|
||||
@@ -156,7 +161,7 @@ onMounted(load)
|
||||
블라인드 해제
|
||||
</button>
|
||||
<button
|
||||
v-if="!it.blocked"
|
||||
v-if="!it.blocked && !isSpam(it)"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@@ -257,6 +262,15 @@ onMounted(load)
|
||||
background: #e5484d22;
|
||||
color: #e5484d;
|
||||
}
|
||||
.badge.spam {
|
||||
background: #f9731622;
|
||||
color: #ea580c;
|
||||
}
|
||||
.block-reason {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #ea580c;
|
||||
}
|
||||
.cat {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-soft, #888);
|
||||
|
||||
@@ -26,24 +26,59 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 포인트 적립내역 모달
|
||||
const POINT_REASONS = { BOARD_WRITE: '게시판 작성' }
|
||||
const POINT_REASONS = {
|
||||
BOARD_WRITE: '게시판 작성',
|
||||
POST_UPVOTED: '게시글 추천받음',
|
||||
POST_DOWNVOTED: '게시글 비추천받음',
|
||||
VOTE_REWARD: '추천 참여',
|
||||
VOTE_CANCEL: '추천 취소',
|
||||
ACCOUNT_ENTRY: '가계부 기록',
|
||||
}
|
||||
function reasonLabel(r) {
|
||||
if (r && r.startsWith('MONTH_COMPLETE_')) {
|
||||
const ym = r.slice('MONTH_COMPLETE_'.length)
|
||||
return `${ym.slice(0, 4)}년 ${parseInt(ym.slice(4), 10)}월 개근 보너스`
|
||||
}
|
||||
return POINT_REASONS[r] || r
|
||||
}
|
||||
|
||||
const _now = new Date()
|
||||
const histYear = ref(_now.getFullYear())
|
||||
const histMonth = ref(_now.getMonth() + 1)
|
||||
const historyOpen = ref(false)
|
||||
const history = ref([])
|
||||
const historyLoading = ref(false)
|
||||
async function openHistory() {
|
||||
historyOpen.value = true
|
||||
|
||||
const isCurrentMonth = computed(() => {
|
||||
const n = new Date()
|
||||
return histYear.value === n.getFullYear() && histMonth.value === n.getMonth() + 1
|
||||
})
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
history.value = await authApi.pointHistory()
|
||||
history.value = await authApi.pointHistory(histYear.value, histMonth.value)
|
||||
} catch {
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
function prevMonth() {
|
||||
if (histMonth.value === 1) { histYear.value--; histMonth.value = 12 }
|
||||
else histMonth.value--
|
||||
loadHistory()
|
||||
}
|
||||
function nextMonth() {
|
||||
if (isCurrentMonth.value) return
|
||||
if (histMonth.value === 12) { histYear.value++; histMonth.value = 1 }
|
||||
else histMonth.value++
|
||||
loadHistory()
|
||||
}
|
||||
async function openHistory() {
|
||||
historyOpen.value = true
|
||||
await loadHistory()
|
||||
}
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
@@ -126,9 +161,8 @@ async function restoreSub() {
|
||||
}
|
||||
}
|
||||
|
||||
// 포인트 기프트콘 교환 — 업데이트 예정
|
||||
function exchangeGiftcon() {
|
||||
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
|
||||
function goShop() {
|
||||
router.push('/settings/shop')
|
||||
}
|
||||
|
||||
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
|
||||
@@ -288,9 +322,9 @@ async function withdraw() {
|
||||
<span class="point-more">내역 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="row row-point" @click="exchangeGiftcon">
|
||||
<span class="k">기프트콘 교환</span>
|
||||
<span class="v"><span class="soon">업데이트 예정</span></span>
|
||||
<button type="button" class="row row-point" @click="goShop">
|
||||
<span class="k">포인트 상점</span>
|
||||
<span class="v"><span class="point-more">›</span></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -376,8 +410,13 @@ async function withdraw() {
|
||||
<strong>포인트 적립내역</strong>
|
||||
<button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button>
|
||||
</div>
|
||||
<div class="ph-month-nav">
|
||||
<button type="button" class="ph-nav-btn" @click="prevMonth">‹</button>
|
||||
<span class="ph-month-label">{{ histYear }}년 {{ histMonth }}월</span>
|
||||
<button type="button" class="ph-nav-btn" :disabled="isCurrentMonth" @click="nextMonth">›</button>
|
||||
</div>
|
||||
<p v-if="historyLoading" class="ph-msg">불러오는 중…</p>
|
||||
<p v-else-if="!history.length" class="ph-msg">적립 내역이 없습니다.</p>
|
||||
<p v-else-if="!history.length" class="ph-msg">이 달 적립 내역이 없습니다.</p>
|
||||
<ul v-else class="ph-list">
|
||||
<li v-for="(h, i) in history" :key="i">
|
||||
<div class="ph-main">
|
||||
@@ -655,6 +694,37 @@ async function withdraw() {
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ph-month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ph-month-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
min-width: 7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ph-nav-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ph-nav-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
.ph-nav-btn:not(:disabled):hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.ph-msg {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { exportBackup, importBackup } from '@/utils/backup'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { appLock, PIN_LENGTH } from '@/composables/appLock'
|
||||
import PinPad from '@/components/PinPad.vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
|
||||
const dialog = useDialog()
|
||||
const ui = useUiStore()
|
||||
@@ -169,6 +170,55 @@ async function setDim(dim, value) {
|
||||
window.desktop?.setWindowSize(w, h)
|
||||
}
|
||||
|
||||
// ===== 가계부 데이터 전체 삭제 =====
|
||||
const resetModalOpen = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetToast = ref(false)
|
||||
let resetToastTimer = null
|
||||
|
||||
function openResetModal() {
|
||||
if (resetting.value) return
|
||||
resetModalOpen.value = true
|
||||
}
|
||||
|
||||
async function doResetWithExport() {
|
||||
resetModalOpen.value = false
|
||||
resetting.value = true
|
||||
try {
|
||||
await exportBackup()
|
||||
await accountApi.deleteAllData()
|
||||
showResetToast()
|
||||
} catch (e) {
|
||||
window.alert(e?.message || '처리에 실패했습니다.')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doResetOnly() {
|
||||
resetModalOpen.value = false
|
||||
const ok = await dialog.confirm(
|
||||
'엑셀 백업 없이 모든 데이터를 삭제합니다. 삭제된 데이터는 복구할 수 없습니다. 계속하시겠습니까?',
|
||||
{ title: '데이터 삭제 확인', okText: '삭제', danger: true },
|
||||
)
|
||||
if (!ok) return
|
||||
resetting.value = true
|
||||
try {
|
||||
await accountApi.deleteAllData()
|
||||
showResetToast()
|
||||
} catch (e) {
|
||||
window.alert(e?.response?.data?.message || e?.message || '삭제에 실패했습니다.')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showResetToast() {
|
||||
resetToast.value = true
|
||||
clearTimeout(resetToastTimer)
|
||||
resetToastTimer = setTimeout(() => { resetToast.value = false }, 3000)
|
||||
}
|
||||
|
||||
async function clearAppData() {
|
||||
if (clearing.value) return
|
||||
const ok = await dialog.confirm(
|
||||
@@ -342,6 +392,14 @@ async function clearAppData() {
|
||||
<span class="row-value">v{{ appVersion }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="row row-btn" :disabled="resetting || clearing" @click="openResetModal">
|
||||
<div class="row-main">
|
||||
<span class="row-label danger">가계부 데이터 초기화</span>
|
||||
<span class="row-sub">내역·계좌·분류·예산·고정지출 등 모든 데이터 삭제</span>
|
||||
</div>
|
||||
<span class="row-value danger">초기화</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
|
||||
<div class="row-main">
|
||||
<span class="row-label danger">App Data 삭제</span>
|
||||
@@ -353,6 +411,44 @@ async function clearAppData() {
|
||||
|
||||
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
|
||||
|
||||
<!-- 데이터 초기화 경고 모달 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="resetModalOpen" class="reset-backdrop" @click.self="resetModalOpen = false">
|
||||
<div class="reset-modal" role="dialog" aria-modal="true">
|
||||
<h2 class="reset-title">가계부 데이터 초기화</h2>
|
||||
<p class="reset-warn">
|
||||
⚠️ 내역·계좌·분류·태그·예산·고정지출·투자 내역이 <b>모두 삭제</b>됩니다.<br>
|
||||
삭제된 데이터는 복구할 수 없습니다.<br><br>
|
||||
엑셀 파일로 먼저 백업하시겠습니까?
|
||||
</p>
|
||||
<div class="reset-actions">
|
||||
<button type="button" class="reset-btn export" @click="doResetWithExport">
|
||||
엑셀로 내보낸 후 삭제
|
||||
</button>
|
||||
<button type="button" class="reset-btn danger" @click="doResetOnly">
|
||||
백업 없이 바로 삭제
|
||||
</button>
|
||||
<button type="button" class="reset-btn cancel" @click="resetModalOpen = false">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 삭제 진행 중 상태바 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="resetting" class="reset-progress-wrap" role="status" aria-label="데이터 삭제 중">
|
||||
<div class="reset-progress-bar"></div>
|
||||
<span class="reset-progress-label">데이터를 삭제하는 중입니다…</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 완료 토스트 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="resetToast" class="reset-toast" role="status">데이터 초기화가 완료되었습니다.</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- PIN 설정 모달 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
|
||||
@@ -564,4 +660,116 @@ async function clearAppData() {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 데이터 초기화 모달 */
|
||||
.reset-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.reset-modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.6rem 1.4rem 1.4rem;
|
||||
border-radius: 14px;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.reset-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #c0392b;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.reset-warn {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.65;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 1.3rem;
|
||||
}
|
||||
.reset-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.reset-btn {
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
.reset-btn.export {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.reset-btn.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.reset-btn.cancel {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 삭제 진행 상태바 */
|
||||
.reset-progress-wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3000;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.6rem 1rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.reset-progress-bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, hsla(160,100%,37%,1) 0%, hsla(160,100%,37%,0.3) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: progress-slide 1.2s linear infinite;
|
||||
}
|
||||
@keyframes progress-slide {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.reset-progress-label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 완료 토스트 */
|
||||
.reset-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3000;
|
||||
padding: 0.65rem 1.3rem;
|
||||
border-radius: 999px;
|
||||
background: #2ecc71;
|
||||
color: #fff;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
||||
animation: toast-in 0.2s ease;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="shop-wrap">
|
||||
<header class="shop-header">
|
||||
<h1 class="shop-title">포인트 상점</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="shop-loading">불러오는 중…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 포인트 잔액 -->
|
||||
<section class="points-bar">
|
||||
<span class="points-label">보유 포인트</span>
|
||||
<span class="points-value">{{ points.toLocaleString('ko-KR') }} P</span>
|
||||
</section>
|
||||
|
||||
<!-- 유료 회원 안내 -->
|
||||
<div v-if="isPremium" class="premium-notice">
|
||||
유료 멤버십 이용 중 — 모든 기능 무제한 사용 가능합니다.<br>
|
||||
포인트 상점 아이템은 무료 회원 전용입니다.
|
||||
</div>
|
||||
|
||||
<!-- 상품 목록 -->
|
||||
<section v-else class="product-list">
|
||||
<!-- 추가 계좌 슬롯 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">🏦</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">추가 계좌 슬롯</div>
|
||||
<div class="product-desc">
|
||||
무료 회원은 종류별 계좌 2개까지 등록 가능합니다.<br>
|
||||
슬롯 1개 구매 시 모든 종류에 +1개 추가 (최대 3개 구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
보유 슬롯: <strong>{{ status.extraWalletSlots }}</strong> / {{ status.maxExtraSlots }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.walletSlotPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="status.extraWalletSlots >= status.maxExtraSlots || buying === 'WALLET_SLOT'"
|
||||
@click="buy('WALLET_SLOT')"
|
||||
>
|
||||
<template v-if="status.extraWalletSlots >= status.maxExtraSlots">최대 구매</template>
|
||||
<template v-else-if="buying === 'WALLET_SLOT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 통계 분석 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">✨</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">AI 통계 분석 {{ status.aiStatPackSize }}회</div>
|
||||
<div class="product-desc">
|
||||
통계 화면에서 AI 재무 코멘트를 받을 수 있습니다.<br>
|
||||
{{ status.aiStatPackSize }}회 이용권 구매 (소진 후 재구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
남은 이용권: <strong>{{ status.aiStatCredits }}</strong>회
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.aiStatPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="buying === 'AI_STAT'"
|
||||
@click="buy('AI_STAT')"
|
||||
>
|
||||
<template v-if="buying === 'AI_STAT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 포인트 적립 안내 -->
|
||||
<section class="earn-guide">
|
||||
<h3 class="earn-title">포인트 적립 방법</h3>
|
||||
<ul class="earn-list">
|
||||
<li>가계부 내역 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시판 글 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시글 추천받기: <strong>5P</strong></li>
|
||||
<li>남의 글 추천하기: <strong>1P</strong></li>
|
||||
<li>한 달 개근 보너스: <strong>최대 100P</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref(true)
|
||||
const buying = ref('')
|
||||
const points = ref(0)
|
||||
const status = ref({
|
||||
extraWalletSlots: 0,
|
||||
aiStatCredits: 0,
|
||||
maxExtraSlots: 3,
|
||||
walletSlotPrice: 500,
|
||||
aiStatPrice: 1000,
|
||||
aiStatPackSize: 10,
|
||||
})
|
||||
|
||||
const isPremium = computed(() => auth.user?.plan === 'PREMIUM')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [pt, st] = await Promise.all([authApi.points(), accountApi.shopStatus()])
|
||||
points.value = pt.points ?? 0
|
||||
status.value = st
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buy(item) {
|
||||
const labels = { WALLET_SLOT: '추가 계좌 슬롯', AI_STAT: `AI 통계 분석 ${status.value.aiStatPackSize}회` }
|
||||
const prices = { WALLET_SLOT: status.value.walletSlotPrice, AI_STAT: status.value.aiStatPrice }
|
||||
|
||||
const ok = await dialog.confirm(
|
||||
`${labels[item]} (${prices[item].toLocaleString('ko-KR')}P)를 구매하시겠습니까?`
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
buying.value = item
|
||||
try {
|
||||
status.value = await accountApi.buyShopItem(item)
|
||||
const pt = await authApi.points()
|
||||
points.value = pt.points ?? 0
|
||||
dialog.alert('구매가 완료되었습니다!')
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.message
|
||||
if (msg === 'SHOP_NOT_ENOUGH_POINTS') {
|
||||
dialog.alert('포인트가 부족합니다.')
|
||||
} else if (msg === 'SHOP_WALLET_SLOT_MAX') {
|
||||
dialog.alert('추가 계좌 슬롯은 최대 3개까지 구매할 수 있습니다.')
|
||||
} else {
|
||||
dialog.alert('구매 중 오류가 발생했습니다.')
|
||||
}
|
||||
} finally {
|
||||
buying.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shop-wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 40px;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg, #f7f8fa);
|
||||
}
|
||||
|
||||
.shop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-bottom: 1px solid var(--color-border, #e8eaed);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #222);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: var(--color-surface, #fff);
|
||||
margin: 12px 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.premium-notice {
|
||||
margin: 12px 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status {
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
.product-buy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.buy-btn:disabled {
|
||||
background: #c0c8d8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earn-guide {
|
||||
margin: 20px 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.earn-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.earn-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
list-style: disc;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.earn-list strong {
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shop-wrap { background: #111; }
|
||||
.shop-header { background: #1c1c1e; border-color: #2c2c2e; }
|
||||
.points-bar, .product-card, .earn-guide { background: #1c1c1e; box-shadow: none; }
|
||||
.premium-notice { background: #052e16; color: #bbf7d0; border-color: #166534; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user