Compare commits
100 Commits
ea9fbc359b
...
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 | |||
| 42406b4c75 | |||
| 16e8973532 | |||
| 0d7e9ba4f5 | |||
| 1525460149 | |||
| cbb3d47bfa | |||
| 1cbf47ff87 | |||
| a7e5bf9c0f | |||
| 9de1949da8 | |||
| 7861eef05b | |||
| a261dd2ca2 |
@@ -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
|
||||
|
||||
@@ -1,44 +1,71 @@
|
||||
# sb_pt
|
||||
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron) 3개 플랫폼을 하나의 코드베이스로 빌드합니다.
|
||||
|
||||
## Recommended IDE Setup
|
||||
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||
- 운영: [app.sblog.kr](https://app.sblog.kr) · 안내/정책 페이지 [www.sblog.kr](https://www.sblog.kr)
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
## 기술 스택
|
||||
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||
- **Pinia** (상태관리), **Vue Router** (라우팅 + 인증 가드)
|
||||
- **Axios** (공통 인스턴스 + 토큰/401 인터셉터)
|
||||
- **Toast UI Editor** + Prism.js (게시판 마크다운 작성/뷰어)
|
||||
- **Capacitor** (안드로이드 하이브리드 앱), **Electron** (Windows 데스크톱)
|
||||
- **Vitest** + `@vue/test-utils` (테스트)
|
||||
|
||||
## 빠른 시작
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예외)
|
||||
```
|
||||
- Node: `^20.19.0 || >=22.12.0`
|
||||
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`, Nginx 가 백엔드 8080 으로 프록시)
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
## 주요 스크립트
|
||||
| 명령 | 설명 |
|
||||
|---|---|
|
||||
| `npm run dev` | 개발 서버 (HMR) |
|
||||
| `npm run build` | `dist/` 정적 빌드 (웹 배포용) |
|
||||
| `npm run build:app` | Capacitor 모드 빌드 (`--mode capacitor`, 앱 전용 env) |
|
||||
| `npm run cap:sync` | 앱 빌드 + `cap sync` (안드로이드 동기화) |
|
||||
| `npm run cap:android` | Android Studio 열기 |
|
||||
| `npm run electron:dev` | Electron 데스크톱 실행 |
|
||||
| `npm run electron:build` | Windows 설치파일(`DonDwaeji-Setup.exe`) 빌드 |
|
||||
| `npm test` | Vitest 실행 |
|
||||
| `npm run lint` | oxlint + eslint (`--fix`) |
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
## 플랫폼별 동작
|
||||
- **웹(PC·모바일)**: 안내 페이지만 노출(앱 다운로드 유도). 실제 이용은 앱에서. (`npm run dev` 는 예외로 전체 앱 표시)
|
||||
- **안드로이드**: Capacitor WebView 패키징. 빌드 전 `.env.capacitor` 의 백엔드 절대 URL 설정 필수 → [docs/ANDROID.md](docs/ANDROID.md)
|
||||
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||
|
||||
### Compile and Minify for Production
|
||||
## 인증
|
||||
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage` 에 저장, `http.js` 인터셉터가 `Authorization` 헤더에 주입
|
||||
- **구글 로그인**(웹/PC=GIS, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(아이디/비번 폼은 `features.js` 플래그로 숨김)
|
||||
- 401 응답 시 토큰 정리 후 로그인 모달, 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
## 주요 기능
|
||||
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
|
||||
- **AI 보조** — 자연어 한 줄 입력("어제 스타벅스 5천원"), 영수증 사진 구조화, 분류·계좌 자동 추천, 통계 화면 재무 코멘트·절약 가이드 (Claude Haiku, 집계만 전송·저장은 사용자 확인 후)
|
||||
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
|
||||
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
|
||||
- **통계** — '이번 달 현황'(예산·수입 대비 통합), 분류별 분석, 순자산 추이, 월말 예상 지출(일회성 큰 지출 제외 결정적 계산), 지난달 대비
|
||||
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
|
||||
- **활동 포인트** — 가계부·게시판 활동 적립, 한 달 개근(모든 날 기록) 랜덤 보너스
|
||||
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
|
||||
- **테마** — 시스템/라이트/다크 모드
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
## 문서
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [docs/FRONTEND.md](docs/FRONTEND.md) | 아키텍처·디렉터리 구조·화면 명세 |
|
||||
| [docs/release-2026-06-06.md](docs/release-2026-06-06.md) | 누적 변경 이력 (릴리스 노트) |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | 안드로이드(Capacitor) 빌드·배포 |
|
||||
| [docs/plan-membership-tiers.md](docs/plan-membership-tiers.md) | 무료/유료 티어 설계 (미구현) |
|
||||
| [deploy/README.md](deploy/README.md) | 프론트 배포(Nginx + Gitea Actions) |
|
||||
| [brand/LAUNCH-PROGRESS.md](brand/LAUNCH-PROGRESS.md) | Play 스토어 출시 진행 상황·로드맵 |
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
## 배포
|
||||
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(Nginx 업로드·reload) 실행
|
||||
- **테스트 게이트**: CI·배포 워크플로 모두 테스트 실패 시 배포 중단
|
||||
- 상세: [deploy/README.md](deploy/README.md)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+49
-31
@@ -1,16 +1,23 @@
|
||||
# Slim Budget — 프론트엔드 (sb_pt)
|
||||
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||
|
||||
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
|
||||
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 하나의 코드베이스로 **웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron)** 3개 플랫폼을 빌드합니다.
|
||||
|
||||
## 기술 스택
|
||||
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||
- **Pinia** (상태관리), **Vue Router** (라우팅)
|
||||
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
|
||||
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
|
||||
- **Capacitor**(안드로이드 하이브리드 앱) · **Electron**(Windows 데스크톱) · **Vitest**(테스트)
|
||||
|
||||
## 브랜딩
|
||||
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
|
||||
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
|
||||
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||
- 로고: 돈돼지(투명 배경) — 헤더/사이드바 로고·파비콘·앱 런처 아이콘. 브랜드 그린 `#00bc7e`
|
||||
|
||||
## 플랫폼별 동작
|
||||
- **웹(PC·모바일)**: 안내 페이지([WebOnlyNotice.vue](../src/components/WebOnlyNotice.vue))만 노출(앱 다운로드 유도). 실제 이용은 앱에서. `npm run dev` 는 예외로 전체 앱 표시
|
||||
- **안드로이드**: Capacitor WebView 패키징 → [ANDROID.md](ANDROID.md)
|
||||
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||
- 기능 플래그 [src/config/features.js](../src/config/features.js): `ID_LOGIN_ENABLED`(아이디 로그인)·`BILLING_ENABLED`(인앱결제)·`ADS_ENABLED`(광고)·`CARD_NOTIF_ENABLED`(카드알림) — 첫 출시 기준 전부 `false`
|
||||
|
||||
## 디렉터리 구조
|
||||
```
|
||||
@@ -21,65 +28,76 @@ src/
|
||||
│ ├── boardApi.js # 게시판
|
||||
│ ├── adminApi.js # 관리자(태그 카테고리)
|
||||
│ └── accountApi.js # 가계부 전체
|
||||
│ ├── billingApi.js # 인앱결제/구독
|
||||
├── stores/ # Pinia (auth, ui, user)
|
||||
├── config/features.js # 기능 플래그(아이디로그인·결제·광고·카드알림)
|
||||
├── router/index.js # 라우트 + 인증 가드
|
||||
├── components/
|
||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
|
||||
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
|
||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter / AppBottomNav
|
||||
│ ├── ui/ # IconBtn / AppModal(전체화면 슬라이드업) / AppDialog / CategoryPicker
|
||||
│ ├── editor/ # MarkdownEditor / MarkdownViewer
|
||||
│ ├── LoginModal.vue / SignupModal.vue
|
||||
│ ├── LoginModal.vue / SignupModal.vue / AppLockScreen.vue(PIN) / AdBanner.vue / WebOnlyNotice.vue
|
||||
├── views/
|
||||
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
|
||||
│ ├── UsersView.vue # 사용자 관리
|
||||
│ ├── HomeView.vue # 홈 대시보드(요약·일별 캘린더)
|
||||
│ ├── board/ # 게시판 (목록/상세/작성)
|
||||
│ ├── account/ # 가계부 (아래 참조)
|
||||
│ └── admin/ # 태그 관리(관리자)
|
||||
└── assets/main.css # 전역 스타일
|
||||
│ └── admin/ # 회원/태그 관리(관리자)
|
||||
└── assets/ # base.css(data-theme 다크모드) 등 전역 스타일
|
||||
```
|
||||
|
||||
## 인증
|
||||
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
|
||||
- **구글 로그인**(웹/PC=Google Identity Services, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(`ID_LOGIN_ENABLED=false` 로 아이디/비번 폼·회원가입 UI 숨김)
|
||||
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
|
||||
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||
|
||||
## 레이아웃 / 반응형
|
||||
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
|
||||
## 레이아웃 / 반응형 / 테마
|
||||
- 그리드 레이아웃 — 사이드바가 좌측 상단(헤더 행)까지, 헤더는 현재 화면 타이틀 표시. 본문 폭 `--page-max`(760px)로 통일
|
||||
- **하단 고정 내비**([AppBottomNav.vue](../src/components/layout/AppBottomNav.vue)): 뒤로·앞으로·홈·새로고침·설정 (안드로이드 소프트키 위 안전영역)
|
||||
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
|
||||
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
|
||||
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
|
||||
- **입력 모달**: 전체화면 슬라이드업([AppModal.vue](../src/components/ui/AppModal.vue), 바깥 닫힘 방지). 셀렉트 대신 **뱃지/칩** 선택, 분류는 공통 [CategoryPicker.vue](../src/components/ui/CategoryPicker.vue)(대/소분류 아코디언)
|
||||
- **테마**: 시스템 / 라이트 / 다크 (설정 화면, `data-theme` 기반)
|
||||
- 모든 액션 버튼은 **아이콘화**(`IconBtn`), `title`로 접근성·툴팁 제공
|
||||
|
||||
## 게시판 (`/board`)
|
||||
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
|
||||
- 댓글, 태그, 관리자 **열람 제한/해제**
|
||||
- **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판(카테고리)
|
||||
- 목록(페이징·태그 필터·검색[제목/본문/댓글]·공지 최상단), 상세, 작성/수정(마크다운·이미지 업로드)
|
||||
- 댓글, 추천(vote), 태그, **공지**(관리자), 관리자 **열람 제한/해제**, 신고
|
||||
|
||||
## 가계부 (`/account`)
|
||||
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
|
||||
|
||||
| 경로 | 화면 | 설명 |
|
||||
|------|------|------|
|
||||
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
|
||||
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
|
||||
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
|
||||
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
|
||||
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
|
||||
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
||||
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**(대분류 집계), 수입대비지출(월말 예상·지난달 대비), 월별 총액, **순자산 추이**(주간) 라인차트, 일별 캘린더 |
|
||||
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 날짜 그룹 접기/펴기, **외화 입력**(환율 자동환산), 내역→고정지출 바로 등록 |
|
||||
| `/account/recurrings` | 고정 지출 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이·'확인 필요'로 생성) |
|
||||
| `/account/wallets` | 계좌 관리 | 은행/**현금**/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부(원금·이자·연회비 분리), 월별 이용내역 |
|
||||
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD(**대/소분류 2단계**), 칩 그리드 관리, 순서변경, 기존 내역에서 가져오기 |
|
||||
| `/account/budget` | 예산 설정 | **월별 예산**(전월/다음달 복사), 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
||||
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
|
||||
|
||||
### 잔액·순자산 모델 (표시)
|
||||
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
||||
- 자산(+) / 부채(−) 부호 모델. 현금은 은행과 동일하게 자산 합산. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
||||
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
|
||||
|
||||
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
|
||||
### 투자 계좌 (계좌 관리 > 투자 탭)
|
||||
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
|
||||
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
|
||||
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
|
||||
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
|
||||
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
|
||||
- **단순형 UI**: **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산(퇴직연금·연금 등 포함). 계좌 펼침 시 투자금/평가액/손익(%) 요약 카드
|
||||
- 입금은 가계부 **이체**(은행→투자)로, 평가액은 순자산·추이에 반영
|
||||
- ℹ️ 백엔드는 종목 단위(매수·매도·시세 자동조회) API 를 유지하나 현재 UI 에는 노출하지 않음. 원화 기준
|
||||
|
||||
## 설정 화면
|
||||
- 하단 내비 → 설정: 계정정보(이름·이메일·비밀번호 변경) · 테마(시스템/라이트/다크) · 앱 잠금(PIN, 1시간 유예) · 앱 버전 · App Data 삭제 · (PC)창 크기 프리셋
|
||||
|
||||
## 개발/빌드
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 개발 서버
|
||||
npm run build # dist/ 정적 빌드
|
||||
npm run dev # 개발 서버(전체 앱 표시)
|
||||
npm run build # dist/ 정적 빌드(웹 배포)
|
||||
npm run cap:sync # 앱 빌드 + 안드로이드 동기화
|
||||
npm run electron:build # Windows 설치파일
|
||||
npm test # Vitest
|
||||
```
|
||||
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
|
||||
- 상세: [../README.md](../README.md) · 앱 [ANDROID.md](ANDROID.md) · 배포 [../deploy/README.md](../deploy/README.md)
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# iOS 하이브리드 앱 (Capacitor)
|
||||
|
||||
Vue `dist` 를 네이티브 WebView로 패키징해 iOS 앱으로 빌드합니다. 안드로이드와 **동일 코드베이스**이며, 플랫폼 분기는 `Capacitor.isNativePlatform()` / `getPlatform() === 'ios'` 로 처리합니다.
|
||||
|
||||
## 구성
|
||||
- `capacitor.config.json` — appId `kr.sblog.slimbudget`(안드와 동일), appName `돈돼지 가계부`, webDir `dist`
|
||||
- `ios/` — 네이티브 프로젝트(커밋됨, 빌드 산출물 `ios/App/build`·`Pods` 는 .gitignore)
|
||||
- **Capacitor 8 = Swift Package Manager(SPM)** 사용 (CocoaPods 아님 — Podfile 불필요)
|
||||
- 웹 게이팅: iOS 는 `isNativePlatform()` 이 true → 안내 페이지가 아닌 전체 앱 표시
|
||||
|
||||
## 사전 준비 (Mac)
|
||||
- **Xcode**(App Store, 대용량) + iOS SDK/시뮬레이터 런타임
|
||||
```bash
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
sudo xcodebuild -license accept
|
||||
xcodebuild -downloadPlatform iOS # 시뮬레이터 런타임(별도 온디맨드 다운로드)
|
||||
```
|
||||
- **Apple Developer Program($99/년)** — 실기기 배포·App Store·Apple 로그인 Capability 에 필요. (시뮬레이터 UI 테스트는 불필요)
|
||||
|
||||
## 최초 셋업 (1회)
|
||||
```bash
|
||||
npm i @capacitor/ios
|
||||
npx cap add ios # ios/ 생성
|
||||
npm run build:app && npx cap sync ios
|
||||
npx capacitor-assets generate --ios # 브랜드 아이콘 생성(assets/ 기준)
|
||||
```
|
||||
|
||||
## 빌드 & 실행
|
||||
```bash
|
||||
npm run build:app && npx cap sync ios # 코드 변경 시마다
|
||||
npx cap open ios # Xcode 열기
|
||||
# 또는 CLI 로 시뮬레이터에 바로:
|
||||
npx cap run ios --target <시뮬레이터-UDID>
|
||||
```
|
||||
시뮬레이터 목록: `xcrun simctl list devices available | grep iPhone`
|
||||
|
||||
### 실기기 실행 (Xcode)
|
||||
1. **데이터 지원 케이블**로 아이폰 연결 → "이 컴퓨터 신뢰" (충전 전용 케이블은 인식 안 됨)
|
||||
2. Xcode → **App 타겟 → Signing & Capabilities → Automatically manage signing → Team 선택**(무료 Apple ID 도 7일 한정 가능)
|
||||
3. 상단 툴바 중앙 **기기 선택**(스킴 `App` 옆) → 연결된 아이폰 → **▶ Run**
|
||||
4. 최초엔 아이폰이 **개발자 모드**를 요구 → Run 시도 후 **설정 → 개인정보 보호 및 보안 → 개발자 모드 → 켬 → 재시동**
|
||||
5. "신뢰할 수 없는 개발자" → **설정 → 일반 → VPN 및 기기 관리 → 개발자 앱 → 신뢰**
|
||||
|
||||
## 필수 설정 (반영 완료)
|
||||
- **번들 ID** `kr.sblog.slimbudget` (안드로이드와 동일 — App Store Connect 에서 그대로 사용)
|
||||
- **Info.plist 권한 문구** (없으면 카메라/사진 접근 시 크래시):
|
||||
- `NSCameraUsageDescription`(영수증·프로필·게시판 촬영)
|
||||
- `NSPhotoLibraryUsageDescription` / `NSPhotoLibraryAddUsageDescription`
|
||||
- **safe-area**: `index.html` 의 `viewport-fit=cover` + CSS `env(safe-area-inset-*)`.
|
||||
⚠️ 전역 `box-sizing: border-box` 에서 고정 `height` 를 쓰면 safe-area 패딩이 높이에 포함돼 콘텐츠가 눌린다 → 헤더는 `min-height`+세로패딩, 하단 내비는 `height: calc(56px + env(safe-area-inset-bottom))` 로 처리(적용됨).
|
||||
|
||||
## Apple 로그인 (Sign in with Apple)
|
||||
> App Store 심사 가이드라인 **4.8**: 제3자 소셜 로그인(구글)을 제공하면 **Apple 로그인도 동등하게 제공**해야 함. iOS 출시에 **사실상 필수**.
|
||||
|
||||
**구현 상태(코드 완료):**
|
||||
- 프론트: `@capacitor-community/apple-sign-in` + [LoginModal.vue](../src/components/LoginModal.vue) Apple 버튼(iOS 전용) + [appleAuth.js](../src/native/appleAuth.js)
|
||||
- ⚠️ 이 플러그인은 Capacitor 7 기준(`capacitor-swift-pm 7.x`) → **patch-package** 로 8.x 로 패치(`patches/`). `npm ci` 후 `postinstall` 이 자동 적용. @capawesome 구글 플러그인과 iOS 공존.
|
||||
- 백엔드: `POST /api/auth/apple` — **nimbus-jose-jwt 로 Apple JWKS(공개키) 서명 검증** + iss/aud(번들ID)/exp 확인, `member.apple_id` 로 구글과 동일하게 이메일 자동연결. `app.apple-client-id`(기본 `kr.sblog.slimbudget`).
|
||||
- `ios/App/App/App.entitlements` — `applesignin` 엔틀먼트 준비됨.
|
||||
|
||||
**작동시키려면(유료 승인 후):**
|
||||
1. Apple Developer 포털: App ID `kr.sblog.slimbudget` 에 **Sign in with Apple 활성화**
|
||||
2. Xcode → Signing & Capabilities → **+ Capability → "Sign in with Apple"** (준비된 `App.entitlements` 연결 + 프로비저닝)
|
||||
3. 테스트: 시뮬레이터 **설정 → Apple ID 로그인** 후 `cap run ios` → Apple 버튼
|
||||
- Sign in with Apple 은 **시뮬레이터에서도 작동**(카메라/푸시와 달리). 단 1~2 가 갖춰져야 함.
|
||||
|
||||
## 배포 (App Store)
|
||||
- **의무 테스트 기간 없음** — 안드로이드(Play)의 "테스터 12명·14일"과 달리, iOS 는 **바로 심사 제출 가능**. TestFlight 은 **선택**.
|
||||
- 절차: App Store Connect 앱 생성 → 아이콘·스크린샷(6.x" iPhone 규격)·App Privacy 작성 → Xcode Archive → 업로드 → 심사(보통 1~3일)
|
||||
- **TestFlight**(선택): 유료 승인 후 빌드 업로드 → **케이블 없이 아이폰에 무선 설치**(데이터 케이블 없을 때 유용). 내부 100명 즉시 / 외부 10,000명(가벼운 베타 심사).
|
||||
|
||||
## 자주 막히는 부분
|
||||
- **SPM 의존성 충돌**: 플러그인이 서로 다른 `capacitor-swift-pm`/`googlesignin-ios` 버전을 요구하면 빌드 실패. Capacitor 8 전용 플러그인을 쓰거나(patch-package) 버전 정렬 필요.
|
||||
- **USB 연결이 충전만 됨**: 충전 전용 케이블 → **데이터 케이블**로 교체. 무선 디버깅도 최초 1회 유선 페어링 필요.
|
||||
- **개발자 모드 메뉴가 없음**: Xcode 로 기기 설치를 **한 번 시도한 뒤** 설정에 항목이 생김(iOS 16+).
|
||||
- **Developer 등록 "보류"**: 개인 등록은 Apple 이 추가 본인확인을 요청할 수 있음 → **Apple ID 이메일 확인 후 회신**해야 진행됨(자동으로 안 풀림).
|
||||
- **카메라/푸시는 시뮬레이터 제한** → 실기기 필요. Apple 로그인은 시뮬레이터 가능.
|
||||
|
||||
## 참고
|
||||
- 안드로이드 빌드: [ANDROID.md](ANDROID.md) · 아키텍처: [FRONTEND.md](FRONTEND.md)
|
||||
- 앱 전용 백엔드 URL: `.env.capacitor` 의 `VITE_API_BASE_URL`(HTTPS 필수)
|
||||
+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>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,393 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* 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>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
327E84D03001463F00AB0467 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
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;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
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;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
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;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
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";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
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;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
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;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
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;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
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;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-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>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"originHash" : "275f5197fc45ecbed402538938c9f019518bacb33270bc32cf5549af9b2891e6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
|
||||
"version" : "11.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "appauth-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/openid/AppAuth-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
|
||||
"version" : "1.7.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "capacitor-swift-pm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||
"state" : {
|
||||
"revision" : "2231987d85b8b0b289320b1d0947b4ae8345cde4",
|
||||
"version" : "8.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googlesignin-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleSignIn-iOS",
|
||||
"state" : {
|
||||
"revision" : "65fb3f1aa6ffbfdc79c4e22178a55cd91561f5e9",
|
||||
"version" : "8.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||
"version" : "8.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b",
|
||||
"version" : "3.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtmappauth",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GTMAppAuth.git",
|
||||
"state" : {
|
||||
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a",
|
||||
"version" : "4.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ion-ios-camera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/ion-ios-camera.git",
|
||||
"state" : {
|
||||
"revision" : "2cd4f06b0ece21b6458e23357280c51f94bfceb8",
|
||||
"version" : "1.0.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ion-ios-filesystem",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/ion-ios-filesystem.git",
|
||||
"state" : {
|
||||
"revision" : "0d81e26e828ff9582807e2339112cedf2e0fab85",
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-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>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,49 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 507 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-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>
|
||||
<string>영수증·프로필·게시판 사진을 촬영할 때 카메라를 사용합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>영수증·프로필·게시판 사진을 첨부할 때 사진 보관함에서 이미지를 불러옵니다.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>촬영한 사진을 사진 보관함에 저장할 때 사용합니다.</string>
|
||||
<key>GIDClientID</key>
|
||||
<string>127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne.apps.googleusercontent.com</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>돈돼지 가계부</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
||||
.package(name: "CapacitorCommunityAppleSignIn", path: "../../../node_modules/@capacitor-community/apple-sign-in"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorCamera", path: "../../../node_modules/@capacitor/camera"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapawesomeCapacitorGoogleSignIn", path: "../../../node_modules/@capawesome/capacitor-google-sign-in")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorCommunityAppleSignIn", package: "CapacitorCommunityAppleSignIn"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorCamera", package: "CapacitorCamera"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapawesomeCapacitorGoogleSignIn", package: "CapawesomeCapacitorGoogleSignIn")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+407
-6
@@ -8,12 +8,14 @@
|
||||
"name": "sb_pt",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/android": "^8.3.4",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/ios": "^8.4.1",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
@@ -43,6 +45,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
@@ -600,6 +603,19 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor-community/apple-sign-in": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/apple-sign-in/-/apple-sign-in-7.1.0.tgz",
|
||||
"integrity": "sha512-df7cA7vfvvvilTtpJKeVvxO31W+py5t3HK9233GBD5MvgAgtGvCqeQ3pI0noNkVsKPF9B1g/po8Mph6s4bVSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/scriptjs": "0.0.2",
|
||||
"scriptjs": "^2.5.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.4.tgz",
|
||||
@@ -1183,9 +1199,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
|
||||
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.1.tgz",
|
||||
"integrity": "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
@@ -1203,6 +1219,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
|
||||
"integrity": "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||
@@ -3552,6 +3577,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scriptjs": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scriptjs/-/scriptjs-0.0.2.tgz",
|
||||
"integrity": "sha512-GFrUgzGYfNX3VWZwMyzr0JrBwzLv5Kes4BQBvo6hXdRy14/GBbpCiVwwB7v1o2xIEvFf+9GyznmYhuesQQjSag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||
@@ -4003,6 +4034,13 @@
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -4778,6 +4816,19 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -5169,6 +5220,25 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -5182,6 +5252,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
@@ -6174,7 +6261,6 @@
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -7458,6 +7544,19 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -7475,6 +7574,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
@@ -8104,7 +8213,6 @@
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
@@ -8499,6 +8607,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-obj": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
|
||||
@@ -8905,6 +9023,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
@@ -8912,6 +9050,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
@@ -8950,6 +9095,16 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonparse": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
|
||||
@@ -8997,6 +9152,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -9762,6 +9927,33 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
@@ -10489,7 +10681,6 @@
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -10819,6 +11010,169 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/open": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -12180,6 +12534,12 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scriptjs": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz",
|
||||
"integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -12244,6 +12604,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.32.6",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
|
||||
@@ -12438,6 +12816,16 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||
@@ -13230,6 +13618,19 @@
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:app": "vite build --mode capacitor",
|
||||
@@ -41,12 +42,14 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/android": "^8.3.4",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/ios": "^8.4.1",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
@@ -76,6 +79,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/@capacitor-community/apple-sign-in/Package.swift b/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
index 94246df..7d9d007 100644
|
||||
--- a/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
+++ b/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
@@ -10,7 +10,7 @@ let package = Package(
|
||||
targets: ["SignInWithApple"])
|
||||
],
|
||||
dependencies: [
|
||||
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
|
||||
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
+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',
|
||||
|
||||
+8
-3
@@ -18,6 +18,10 @@ export const authApi = {
|
||||
googleClientId() {
|
||||
return http.get('/auth/google-client-id')
|
||||
},
|
||||
// 애플 로그인/가입 — Sign in with Apple 에서 받은 identity token 전달
|
||||
appleLogin(payload) {
|
||||
return http.post('/auth/apple', payload)
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
@@ -43,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() {
|
||||
|
||||
@@ -76,6 +76,14 @@ export const boardApi = {
|
||||
unblockComment(commentId) {
|
||||
return http.post(`/board/comments/${commentId}/unblock`)
|
||||
},
|
||||
// 신고 관리 (관리자) — 신고된 글/댓글 목록
|
||||
reports() {
|
||||
return http.get('/board/reports')
|
||||
},
|
||||
// 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화
|
||||
dismissReport(targetType, targetId) {
|
||||
return http.post('/board/reports/dismiss', { targetType, targetId })
|
||||
},
|
||||
block(id, reason) {
|
||||
return http.post(`/board/posts/${id}/block`, { reason })
|
||||
},
|
||||
|
||||
+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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
||||
import { isNativeApple, nativeAppleCredential } from '@/native/appleAuth'
|
||||
import { ID_LOGIN_ENABLED } from '@/config/features'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -64,6 +65,33 @@ async function handleNativeGoogle() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 애플 로그인 (iOS 전용) =====
|
||||
const isApple = isNativeApple()
|
||||
|
||||
async function handleNativeApple() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { identityToken, name } = await nativeAppleCredential()
|
||||
loading.value = false
|
||||
if (!identityToken) return
|
||||
error.value = null
|
||||
loading.value = true
|
||||
await auth.appleLogin(identityToken, name, form.rememberMe)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
router.push(redirect || '/')
|
||||
} catch (e) {
|
||||
// 사용자가 취소(USER_CANCELLED/1001 등)한 경우는 조용히 무시
|
||||
const msg = `${e?.code || ''} ${e?.message || ''}`
|
||||
if (!/cancel|1001/i.test(msg)) {
|
||||
error.value = e.response?.data?.message || '애플 로그인에 실패했습니다.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
|
||||
function loadGisScript() {
|
||||
if (window.google?.accounts?.id) return Promise.resolve()
|
||||
@@ -208,6 +236,20 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<div v-else ref="googleBtn" class="google-btn"></div>
|
||||
</div>
|
||||
|
||||
<!-- 애플 로그인 — iOS 전용(App Store 가이드라인 4.8 대응). 서버 설정 불필요 -->
|
||||
<button
|
||||
v-if="isApple"
|
||||
type="button"
|
||||
class="apple-native"
|
||||
:disabled="loading"
|
||||
@click="handleNativeApple"
|
||||
>
|
||||
<svg class="a-logo" viewBox="0 0 384 512" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
Apple로 계속하기
|
||||
</button>
|
||||
|
||||
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
@@ -339,6 +381,32 @@ h2 {
|
||||
.google-native .g-logo {
|
||||
flex: none;
|
||||
}
|
||||
/* Apple 로그인 버튼 — HIG 권장(검은 배경 + 흰 로고/텍스트) */
|
||||
.apple-native {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0.6rem auto 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #000;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.apple-native:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.apple-native .a-logo {
|
||||
flex: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -74,11 +74,18 @@ function goSettings() {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
height: 56px;
|
||||
/* 안드로이드 소프트키(시스템 내비게이션 바) 위에 위치 */
|
||||
/* 아이콘 행은 항상 56px 확보 + 그 아래로 홈 인디케이터(safe-area) 공간을 더한다.
|
||||
(box-sizing:border-box 전역이라 height 에 인셋을 더해야 아이콘 행이 눌리지 않음) */
|
||||
height: calc(56px + env(safe-area-inset-bottom, 0));
|
||||
/* 안드로이드 소프트키·iOS 홈 인디케이터 영역 확보 */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-border);
|
||||
/* 스크롤 콘텐츠와 시각적으로 분리(iOS 탭바처럼 은은한 상단 그림자) */
|
||||
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
:global([data-theme='dark']) .bottom-nav {
|
||||
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
|
||||
@@ -19,7 +19,7 @@ const TITLES = {
|
||||
'demo-locked': '둘러보기',
|
||||
'account-entries': '가계부 내역',
|
||||
'account-stats': '통계',
|
||||
'account-recurrings': '고정 지출',
|
||||
'account-recurrings': '정기결제',
|
||||
'account-wallets': '계좌 관리',
|
||||
'account-categories': '분류 관리',
|
||||
'account-budget': '예산 설정',
|
||||
@@ -28,6 +28,7 @@ const TITLES = {
|
||||
users: '회원 관리',
|
||||
'admin-tags': '태그 관리(관리자)',
|
||||
'admin-default-categories': '기본 분류 설정',
|
||||
'admin-reports': '신고 관리',
|
||||
settings: '설정',
|
||||
'settings-account': '계정정보',
|
||||
'settings-account-edit': '가입정보 변경',
|
||||
@@ -79,8 +80,11 @@ async function handleLogout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 1.5rem;
|
||||
/* height 대신 min-height — 네이티브에서 layout-top 이 상단 safe-area 패딩을 더하므로
|
||||
(box-sizing:border-box) 고정 height 면 콘텐츠가 눌린다. min-height 로 헤더가 커지게. */
|
||||
min-height: 56px;
|
||||
/* 세로 패딩으로 버튼/타이틀이 하단 구분선에 붙지 않게 (상단 패딩은 네이티브에서 layout-top 이 override). */
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -140,7 +144,8 @@ async function handleLogout() {
|
||||
display: inline-flex;
|
||||
}
|
||||
.app-header {
|
||||
padding: 0 1rem;
|
||||
/* 세로 패딩 유지(로그인 버튼이 하단 구분선에 붙지 않게). 상단은 네이티브에서 layout-top override. */
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/* 좁은 화면(폰/앱): 브랜드명 텍스트 숨기고 로고만 — 두 줄 줄바꿈 방지 */
|
||||
.brand-text {
|
||||
|
||||
@@ -25,6 +25,8 @@ const icons = {
|
||||
board: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
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>
|
||||
|
||||
@@ -51,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>
|
||||
@@ -106,6 +113,10 @@ const icons = {
|
||||
<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.sliders" />
|
||||
<span>기본 분류 설정</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/reports" 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.flag" />
|
||||
<span>신고 관리</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -115,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. 광고
|
||||
- 현재 서비스에는 광고가 표시되지 않습니다.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// 네이티브 Sign in with Apple 래퍼 (iOS 전용). 플러그인은 지연 import.
|
||||
// 반환된 identity token(JWT)의 aud 는 앱 번들 ID → 백엔드 /api/auth/apple 가 JWKS 로 검증한다.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// Apple 로그인은 iOS 네이티브에서만 노출한다(웹/안드로이드는 별도 흐름 필요 — 현재 미지원).
|
||||
export function isNativeApple() {
|
||||
return Capacitor.getPlatform() === 'ios'
|
||||
}
|
||||
|
||||
// Sign in with Apple 수행 → { identityToken, name }.
|
||||
// 이름(givenName/familyName)은 애플이 '최초 인증 시에만' 주므로 신규 가입 채움용으로 함께 반환.
|
||||
export async function nativeAppleCredential() {
|
||||
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
|
||||
const result = await SignInWithApple.authorize({
|
||||
clientId: 'kr.sblog.slimbudget.app',
|
||||
redirectURI: 'https://app.sblog.kr/auth/apple/callback',
|
||||
scopes: 'email name',
|
||||
})
|
||||
const r = result?.response || {}
|
||||
const name = [r.givenName, r.familyName].filter(Boolean).join(' ').trim() || null
|
||||
return { identityToken: r.identityToken || null, name }
|
||||
}
|
||||
+50
-1
@@ -104,6 +104,12 @@ const router = createRouter({
|
||||
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/reports',
|
||||
name: 'admin-reports',
|
||||
component: () => import('../views/admin/ReportAdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
||||
path: '/account',
|
||||
@@ -133,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',
|
||||
@@ -149,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 },
|
||||
},
|
||||
// 설정 (앱 하단 내비게이션 → 설정)
|
||||
{
|
||||
@@ -170,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 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
+9
-1
@@ -77,6 +77,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
async function appleLogin(identityToken, name, rememberMe = true) {
|
||||
const res = await authApi.appleLogin({ identityToken, name, rememberMe })
|
||||
token.value = res.token
|
||||
user.value = res.member
|
||||
exitDemo()
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
|
||||
async function signup(payload) {
|
||||
return authApi.signup(payload)
|
||||
@@ -123,7 +131,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
}
|
||||
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, appleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+130
-4
@@ -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>
|
||||
<p class="today">{{ monthLabel }} 가계부 요약</p>
|
||||
<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" />
|
||||
@@ -358,10 +480,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">설정된 예산이 없습니다.</p>
|
||||
</div>
|
||||
</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()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
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">
|
||||
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
||||
<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);
|
||||
|
||||
+476
-122
@@ -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,13 +1002,11 @@ async function analyzePasted() {
|
||||
|
||||
onMounted(async () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
||||
// 고정지출은 유료 전용 — 무료 회원은 호출 자체를 건너뛴다(403/업그레이드 리다이렉트 방지)
|
||||
if (isPremium.value) {
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
||||
}
|
||||
// 정기결제은 무료도 2건까지 사용 가능 → 모든 로그인 사용자에 대해 실행
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
||||
}
|
||||
load()
|
||||
loadTagOptions()
|
||||
@@ -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,16 +1167,13 @@ 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>
|
||||
|
||||
<form class="entry-form" @submit.prevent="submit">
|
||||
</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">
|
||||
<input
|
||||
@@ -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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 영수증 등록 방식 선택 (카메라/갤러리) -->
|
||||
<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);
|
||||
@@ -1718,6 +2036,11 @@ button.primary {
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
/* 전체화면 모달(entry)만: 닫기 버튼을 상태바/노치 아래로 — 중앙 모달엔 적용 안 함 */
|
||||
.modal.entry-modal .close {
|
||||
top: calc(0.5rem + env(safe-area-inset-top));
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
@@ -2014,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;
|
||||
@@ -2115,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, {
|
||||
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))
|
||||
},
|
||||
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 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,178 +166,71 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabbar">
|
||||
<div class="tabs">
|
||||
<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" />
|
||||
<div class="wallet-head">
|
||||
<button
|
||||
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>
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="name">{{ w.name }}</span>
|
||||
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ w.issuer }}
|
||||
<template v-if="(w.type === 'BANK' || w.type === 'MINUS') && w.accountNumber">
|
||||
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
|
||||
<button
|
||||
type="button" class="acct-eye"
|
||||
:title="revealedAccts.has(w.id) ? '가리기' : '전체 보기'"
|
||||
@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>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div class="actions" @click.stop>
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운: 투자는 투자금·평가액 요약, 그 외는 계좌별 내역 -->
|
||||
<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>
|
||||
<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" :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">
|
||||
{{ w.issuer }}
|
||||
<template v-if="(w.type === 'BANK' || w.type === 'MINUS') && w.accountNumber">
|
||||
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
|
||||
<button
|
||||
type="button" class="acct-eye"
|
||||
:title="revealedAccts.has(w.id) ? '가리기' : '전체 보기'"
|
||||
@click.stop="toggleReveal(w.id)"
|
||||
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
|
||||
</template>
|
||||
<template v-if="w.type === 'LOAN'">
|
||||
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}원</span>
|
||||
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="balance-wrap">
|
||||
<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 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>
|
||||
<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">
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
|
||||
+102
-258
@@ -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 = '기준 금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
|
||||
} else {
|
||||
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,
|
||||
}
|
||||
if (!form.monthly || form.monthly <= 0) {
|
||||
formError.value = '월 예산 금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
category: form.category.trim(),
|
||||
fixed: false,
|
||||
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>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<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>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const busyKey = ref(null) // 처리 중인 대상 키 (버튼 비활성화용)
|
||||
|
||||
const CATEGORY_LABEL = { community: '커뮤니티', saving: '짠테크 수다방', tips: '재테크 팁' }
|
||||
|
||||
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 || '게시판'
|
||||
}
|
||||
function linkTo(it) {
|
||||
return `/board/${it.category || 'community'}/${it.postId}`
|
||||
}
|
||||
function formatTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
return d.toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await boardApi.reports()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '신고 목록을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function run(it, fn, successMsg) {
|
||||
busyKey.value = keyOf(it)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
if (successMsg) await dialog.alert(successMsg)
|
||||
} catch (e) {
|
||||
await dialog.alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||
} finally {
|
||||
busyKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 글 블라인드 (사유 입력)
|
||||
async function blockPost(it) {
|
||||
const reason = await dialog.prompt('블라인드 사유를 입력하세요. (열람 제한 안내에 표시)', {
|
||||
title: '글 블라인드',
|
||||
okText: '블라인드',
|
||||
defaultValue: '신고 누적으로 블라인드 처리되었습니다.',
|
||||
})
|
||||
if (reason === null) return
|
||||
await run(it, () => boardApi.block(it.postId, reason || null), '블라인드 처리했습니다.')
|
||||
}
|
||||
|
||||
// 블라인드 해제 (글/댓글) — 신고 기록도 초기화됨
|
||||
async function unblock(it) {
|
||||
const ok = await dialog.confirm('블라인드를 해제하고 신고 기록을 초기화할까요?', {
|
||||
title: '블라인드 해제',
|
||||
okText: '해제',
|
||||
})
|
||||
if (!ok) return
|
||||
const call = it.targetType === 'POST'
|
||||
? () => boardApi.unblock(it.postId)
|
||||
: () => boardApi.unblockComment(it.targetId)
|
||||
await run(it, call, '블라인드를 해제했습니다.')
|
||||
}
|
||||
|
||||
// 신고 무시 — 블라인드 없이 신고 기록만 삭제
|
||||
async function dismiss(it) {
|
||||
const ok = await dialog.confirm('이 신고를 무시하고 목록에서 제거할까요? (블라인드하지 않고 신고 기록만 삭제)', {
|
||||
title: '신고 무시',
|
||||
okText: '무시',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
await run(it, () => boardApi.dismissReport(it.targetType, it.targetId))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="report-admin">
|
||||
<div class="head">
|
||||
<p class="desc">
|
||||
신고 누적 <strong>5건</strong> 이상 또는 스팸 키워드 포함 시 자동 블라인드됩니다.
|
||||
관리자는 여기서 내용을 검토해 <b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b>로 정리할 수 있습니다.
|
||||
</p>
|
||||
<button class="reload" type="button" :disabled="loading" @click="load">↻ 새로고침</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중…</p>
|
||||
|
||||
<p v-else-if="!items.length" class="msg empty">🎉 신고된 글·댓글이 없습니다.</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="count">
|
||||
총 <strong>{{ items.length }}</strong>건
|
||||
<span v-if="blockedCount"> · 블라인드 {{ blockedCount }}건</span>
|
||||
</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="it in items" :key="keyOf(it)" class="card" :class="{ blocked: it.blocked }">
|
||||
<div class="row1">
|
||||
<span class="badge" :class="it.targetType === 'POST' ? 'post' : 'comment'">
|
||||
{{ it.targetType === 'POST' ? '글' : '댓글' }}
|
||||
</span>
|
||||
<span class="cat">{{ categoryLabel(it.category) }}</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>
|
||||
|
||||
<RouterLink :to="linkTo(it)" class="title-link">
|
||||
<span v-if="it.targetType === 'COMMENT'" class="on-post">[{{ it.title }}] 의 댓글</span>
|
||||
<span v-else class="ttl">{{ it.title }}</span>
|
||||
</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>
|
||||
<button
|
||||
v-if="it.targetType === 'POST' && !it.blocked"
|
||||
class="btn danger"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="blockPost(it)"
|
||||
>
|
||||
블라인드
|
||||
</button>
|
||||
<button
|
||||
v-if="it.blocked"
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="unblock(it)"
|
||||
>
|
||||
블라인드 해제
|
||||
</button>
|
||||
<button
|
||||
v-if="!it.blocked && !isSpam(it)"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="dismiss(it)"
|
||||
>
|
||||
신고 무시
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-admin {
|
||||
padding: 0.5rem 0 2rem;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.desc {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reload {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.msg {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
}
|
||||
.msg.error {
|
||||
color: #e5484d;
|
||||
}
|
||||
.msg.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.count {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.card.blocked {
|
||||
border-color: #e5484d55;
|
||||
background: #e5484d0d;
|
||||
}
|
||||
.row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.badge.post {
|
||||
background: #00bc7e22;
|
||||
color: #00996a;
|
||||
}
|
||||
.badge.comment {
|
||||
background: #3b82f622;
|
||||
color: #2563eb;
|
||||
}
|
||||
.badge.blind {
|
||||
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);
|
||||
}
|
||||
.report-count {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #e5484d;
|
||||
}
|
||||
.time {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-soft, #999);
|
||||
}
|
||||
.title-link {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.on-post {
|
||||
color: var(--color-text-soft, #777);
|
||||
font-weight: 500;
|
||||
}
|
||||
.content {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-soft, #555);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.author {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-soft, #999);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
.btn.danger {
|
||||
border-color: #e5484d;
|
||||
color: #e5484d;
|
||||
}
|
||||
</style>
|
||||
@@ -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