Compare commits

...

32 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 23:23:08 +09:00
ByungCheol ee7a1fb471 docs(brand): 백로그에 AI 기능(입력 보조·재무 코멘트) 추가 — 출시 전 적용 희망 2026-06-30 23:02:41 +09:00
ByungCheol 1a8aa8e13c chore: versionCode 3 / 1.0.2 (v3) + 진행상황 문서 정리
CI / build (push) Failing after 10m53s
- 이번 세션 적용분(외화·월별예산·전체화면모달·아코디언·계좌셀렉트·잠금유예) 정리
- 현재 상태(비공개 테스트 게시·검토통과, 테스터 opt-in 대기)로 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:45:32 +09:00
ByungCheol e87bc244fe docs(brand): 월별 예산·복사 등 진행상황 갱신 2026-06-30 22:37:09 +09:00
ByungCheol f8369f5108 feat: 예산 월별 분리 + 전월/다음달 복사 버튼
CI / build (push) Failing after 12m14s
- budgets/createBudget에 year/month 전달, copyBudget 추가
- BudgetView: '전월 예산 가져오기' / '다음 달로 복사' 버튼
- 백업 export는 현재 월 예산을 담음

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:35:49 +09:00
ByungCheol 80d7b8d552 fix: 내역추가 계좌 셀렉트 — 안내문 종류별 표시 + 옵션 이름만
CI / build (push) Failing after 12m23s
- 계좌 선택 안내문을 선택한 종류에 맞게('계좌/카드/대출/증권 선택')
- 계좌·카드 옵션을 '이름 (은행)' → 이름만(없으면 은행/카드사) 표시로 간결화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:14:02 +09:00
ByungCheol 764436ebc9 fix: 분류 관리 탭 줄바꿈 — 순서변경 버튼 추가로 '지출분류' 두 줄 깨짐 수정
CI / build (push) Failing after 12m13s
- 탭 텍스트 white-space:nowrap, 탭 영역 flex:none(안 줄어듦)
- tabs-row flex-wrap: 좁으면 액션 버튼이 아래 줄로

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:09:12 +09:00
ByungCheol 970e9e14ad feat: 내역 추가/수정 전체화면 모달 + 분류 대분류 아코디언
CI / build (push) Failing after 15m22s
- 내역 추가/수정 폼을 전체화면 모달로(바깥 영역 없음·× 로만 닫힘)
  → 입력 중 바깥 실수 터치로 닫히는 불편 해소. PC는 내용 폭 460px 가운데정렬
- 분류 화면: 대분류 삼각형(▶) 토글로 소분류 펼침/접힘(기본 접힘, 개수 뱃지)
  소분류 추가 시 해당 대분류 자동 펼침. 순서변경 모드에선 모두 펼침

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:06:02 +09:00
ByungCheol f996075da7 fix: 외화 입력폼 다듬기 — 자동 버튼 높이 일치 + 환율 4자리 반올림
CI / build (push) Failing after 15m22s
- 환율 input과 '↻ 자동' 버튼을 한 행(stretch)으로 묶어 높이 일치
- 자동조회 환율을 소수점 4자리로 반올림(입력폼 step·표시 일관)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:56:04 +09:00
ByungCheol 5795f328a1 fix: 앱 잠금 유예시간 도입 — 잦은 PIN 입력 불편 해소
CI / build (push) Failing after 12m14s
- 백그라운드 전환 즉시 잠그던 것을, 1시간 이상 백그라운드였을 때만 잠금
- 잠깐 다른 앱(계산기 등) 다녀오는 정도로는 PIN 안 뜸
- 완전 종료/재시작은 onMounted 에서 그대로 잠금
- 카메라/갤러리/공유 suppress 는 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 +09:00
ByungCheol 4f08b47fc3 docs(brand): 외화 결제 기능 완료 기록 2026-06-30 21:38:19 +09:00
ByungCheol dab6dc3411 feat: 외화 입/지출 입력 — 통화 선택 + 환율 자동조회 → 원화 환산
CI / build (push) Failing after 15m36s
- 입력폼: 통화 드롭다운(KRW 기본 + 15개), 외화면 외화금액+환율 입력
  → 환산 원화 자동 계산(amount). 환율은 /fx/rate 자동조회('↻ 자동') + 수정 가능
- 저장: 외화면 currency/originalAmount/exchangeRate 전송, amount=환산 원화
- 목록 상세에 'USD 10.00 @1,350' 외화 뱃지 표시
- accountApi.fxRate 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:37:24 +09:00
ByungCheol caa591a16d chore: versionCode 2 / 1.0.1 (v2 비공개 테스트 빌드) + 백로그 갱신 2026-06-29 21:42:05 +09:00
ByungCheol 971e08282f feat: 분류 순서변경 보호 — 기본 잠금 + '순서변경' 버튼으로만 drag
CI / build (push) Failing after 15m14s
- 스크롤 중 실수로 순서가 바뀌는 것 방지
- 기본은 drag&drop 비활성, '순서변경' 토글 켤 때만 Sortable 생성 + 손잡이(≡) 표시
- 끄면(완료) destroy. 안내 문구도 모드별로 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:36:16 +09:00
ByungCheol f42805f96e docs(brand): 백로그 #6 앱잠금-카메라 버그 수정완료 표시 2026-06-29 20:27:30 +09:00
ByungCheol a0aba1d578 fix: 앱 잠금이 카메라/갤러리/공유 복귀 시 오작동하던 문제 수정
CI / build (push) Failing after 15m40s
- 원인: visibilitychange(숨김)로 백그라운드를 감지하는데 카메라/파일선택/공유
  호출 시에도 웹뷰가 숨겨져 잠금이 걸렸음(복귀 시 PIN 화면)
- appLock.suppressLock(): 네이티브 동작 직전 일시중지 창을 열어 그 복귀는 잠그지 않음
- 파일선택(input[type=file])은 App.vue 전역 클릭 리스너로 일괄 처리
- 카메라(OCR)·공유(백업)는 호출부에서 직접 suppressLock 호출
- 복귀(visible) 시 일시중지 창 해제

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:25:50 +09:00
ByungCheol ce7488076a docs(brand): 백로그에 앱잠금-카메라복귀 PIN 오작동 버그 추가 2026-06-29 17:48:19 +09:00
ByungCheol 2eaf8695b0 docs(brand): 출시 후 업데이트 백로그 추가(업데이트안내·신고글관리·포인트정책·분류순서변경보호) 2026-06-29 09:52:42 +09:00
ByungCheol f23b4963a9 feat: 분류 빈 상태에 '기본 분류 불러오기' CTA 추가
CI / build (push) Failing after 12m57s
- 신규 유저 온보딩(홈 시작하기 → 분류) 흐름이 빈 분류 화면에서 끊기던 문제 보완
- 빈 상태에 이모지·안내 + '기본 분류 불러오기' 버튼(importDefaults) 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:33:31 +09:00
ByungCheol b50a81028f fix: 하단 내비 뒤로 버튼도 하드웨어 뒤로와 동일 동작
CI / build (push) Failing after 15m32s
- 통합 핸들러 appBack.handleBack 추출: 오버레이 닫기 → 화면 뒤로 →
  최상위에서 '한 번 더 누르면 종료' 토스트(네이티브)
- 하단 내비 뒤로 버튼이 옛 로직(홈에서 무반응)이라 하드웨어 뒤로와
  동작이 달랐던 문제 해결 — 둘이 같은 핸들러 사용
- capacitor.js 중복 로직 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:25:57 +09:00
ByungCheol 6972cb5a63 docs(brand): 출시 전 다듬기 진행상황 갱신(빈상태·오프라인·앱잠금·에러폴백·뒤로가기) 2026-06-29 09:11:17 +09:00
ByungCheol bd5f1afd47 feat: 전역 에러 폴백 + 안드로이드 뒤로가기 동작 개선
CI / build (push) Failing after 12m27s
전역 에러 처리(흰 화면 방지):
- app.config.errorHandler + onErrorCaptured → 렌더/라이프사이클 오류 시
  빈 화면 대신 '문제가 발생했어요 + 새로고침' 폴백 화면 표시

안드로이드 뒤로가기 우선순위 개선:
- 잠금 화면이면 우회 불가(종료) → 열린 오버레이(회원가입/로그인/비번/사이드바)
  닫기 → 화면 뒤로 → 최상위에서 '한 번 더 누르면 종료'(실수 종료 방지)
- 종료 안내 토스트(커스텀 이벤트, 플러그인 없이 App.vue 렌더)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:09:01 +09:00
ByungCheol bac3ce1ded feat: 앱 잠금(PIN) 추가 — 가계부 보안 강화
CI / build (push) Failing after 10m50s
- 자체 구현(네이티브 플러그인 없음): PIN은 SHA-256 해시만 저장, 기본 OFF 옵트인
- 앱 시작(저장 세션 재진입)·백그라운드 복귀 시 잠금 화면 노출
- 설정에 앱 잠금 토글 + PIN 설정(입력→확인 2단계) 모달
- 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃'(잠금 해제 후 로그아웃)
- 신규/민감정보 앱 신뢰도 향상

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:45:38 +09:00
ByungCheol 3f9b0daf53 feat: 오프라인·네트워크 오류 안내 배너 추가
CI / build (push) Failing after 13m9s
- http 인터셉터: 응답 없는 네트워크 오류(오프라인/타임아웃/서버다운) 시
  net:error 이벤트 발생 (취소 요청 제외)
- App.vue: navigator.onLine + online/offline + net:error 구독 →
  상단 고정 배너로 안내(+새로고침). 서버 응답 없음은 5초 후 자동 숨김
- 폐쇄 테스트(다양한 네트워크 환경)에서 무반응 대신 명확한 피드백 제공

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:33:20 +09:00
ByungCheol cbe469552d feat: 신규 유저 빈 상태 행동 유도(온보딩) 추가
CI / build (push) Failing after 15m26s
- 홈 대시보드: 데이터 없을 때 '시작하기' 카드(계좌→분류→내역 3단계 안내, 닫기 가능)
- 가계부 내역: 빈 상태를 '첫 내역 추가' CTA로 (필터 결과 없음과 구분)
- 계좌 관리: 빈 상태를 '계좌 추가' CTA로
- 스토어 신규 유저(데이터 0)의 첫인상·활성화 개선

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:26:02 +09:00
ByungCheol 659ea3b2fa docs(brand): Play 스토어 출시 준비 문서·스크린샷 정리
- LAUNCH-PROGRESS.md: 출시 준비 종합 진행 상황 + 남은 로드맵(단일 기준점)
- play-store-listing.md: 등록정보·데이터안전·콘텐츠등급 자료
- production-access-draft.md: 프로덕션 액세스 신청서 초안
- screenshots/: 스토어용 스크린샷 7컷

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:40:19 +09:00
29 changed files with 1776 additions and 86 deletions
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId "kr.sblog.slimbudget" applicationId "kr.sblog.slimbudget"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 3
versionName "1.0" versionName "1.0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+132
View File
@@ -0,0 +1,132 @@
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
---
## ✅ 완료된 작업
### 출시 전략 결정
- **무료 앱**으로 첫 출시 (수익화 기능은 순차 도입)
- 첫 출시 기능 플래그 (`src/config/features.js`, 전부 `false`):
- `BILLING_ENABLED=false` — 인앱결제 스텁이라 결제 없이 프리미엄 부여되는 악용 차단
- `ADS_ENABLED=false` — 애드핏 미승인 + 데이터 안전 양식 단순화(광고 식별자 미수집)
- `CARD_NOTIF_ENABLED=false` — NotificationListenerService 정책 심사 회피
- 패키지명 `kr.sblog.slimbudget` **유지**(출시 후 영구 고정. 사용자 비노출이라 무방)
### 인프라 / 정책 페이지
- **www.sblog.kr** 도메인 + 인증서 확장 (app.sblog.kr와 동일 dist 서빙)
- 공개 URL: `/privacy`, `/terms`, `/account-deletion` (모두 200 확인)
- 개인정보처리방침 광고 문구 → "현재 광고 없음 + 향후 도입 가능" 미래형으로 정합화
### 빌드 / 서명
- **AAB**: `android/app/build/outputs/bundle/release/app-release.aab`
- targetSdk 36, versionCode 1, versionName 1.0
- 업로드 키(CN=Don Dwaeji) 서명 확인 (디버그 아님)
- 결제·광고·카드알림 전부 OFF 클린 (NotificationListener 권한 없음 검증)
- 키스토어: `android/upload-keystore.jks` + `android/key.properties` (gitignore, **이 PC에만 존재 — 분실 시 업데이트 불가, 반드시 백업**)
### 스토어 그래픽 (`brand/store/`)
- `icon-512.png` (512×512 불투명)
- `feature-1024x500.png` / `feature-1024x500-text.png` (피처그래픽, 택1)
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
### 등록 문서
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
- `brand/production-access-draft.md` — 프로덕션 액세스 신청서 초안(4문항)
### 코드 수정 (배포 완료)
- 인앱결제 진입점 비활성화 (UpgradeView "곧 출시 예정", AccountInfoView 정기결제 카드 숨김)
- 광고 비활성화 (AdBanner + App.vue 게이팅, 하단 광고 여백 제거)
- 둘러보기(데모) 모드 메뉴 접근 버그 수정 (AppHeader 햄버거 `demo.on` 노출)
- 계정삭제 공개 페이지 추가
### 테스트 데이터 (캡처용, `sb_bt/scripts/`, gitignore 로컬 전용)
- 테스트 계정 `kenneth73510@gmail.com` (DB로 PREMIUM 부여)
- `seed-test-kenneth.sql`(계정+가계부), `seed-test-board.sql`(게시판 더미)
- `cleanup-test-board.sql`(게시판 더미 삭제 — **캡처 후 정리 완료**)
---
## 🚧 현재 상태 (2026-06-30)
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
-**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
-**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
---
## 📋 남은 로드맵 (프로덕션까지)
1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
3. **v3 AAB 업로드** — 이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
4. 승인 → **프로덕션 출시**
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
- **테스터 12명 이상 · 14일 연속** 폐쇄 테스트 후 프로덕션 신청 가능
- 실기기·실계정 필수 (에뮬·봇·중복 불인정)
- 14일 카운트 = "릴리스 승인 + 12명 opt-in" 둘 다 충족 시점부터
- 테스터 확보: 지인 비공개 방침 → 품앗이 커뮤니티(무료·안전) 또는 평판 좋은 유료 서비스(실사용 보장형만)
### 익명 셋업 체크리스트
- [ ] 전용 Gmail 생성 (개발자 연락처 + 품앗이용)
- [ ] 공개 개발자 이름 → 브랜드명 (법적이름은 구글 내부 전용, 비공개)
- [ ] 연락처 이메일(계정+앱) → 전용 메일
- [ ] 거래자 상태 → **비거래자(Non-trader)** (주소 비공개)
- [ ] 품앗이 노출은 전용 Gmail로만
---
## ✅ 적용 완료 (전부 웹/소스 배포됨)
**v2(versionCode 2, 1.0.1, 06-29)에 포함 — 비공개 테스트 게시·검토통과**
- 신규 유저 빈 상태 CTA(홈 '시작하기'·내역·계좌) / 오프라인·네트워크 오류 배너
- 앱 잠금(PIN, 옵트인) + 카메라/갤러리 복귀 오작동 수정
- 전역 에러 폴백(흰 화면 방지) / 안드로이드 뒤로가기 통합(오버레이→뒤로→종료토스트)
- 분류 순서변경 보호('순서변경' 토글)
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
---
## 📌 남은 백로그 (출시 후 업데이트)
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. **관리자 신고글 목록 페이지**
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
- **AI 입력 보조** (편의성, 1순위)
-**자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
-**영수증 사진 → AI 구조화** — 멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
-**자동 분류 추천** — 가맹점/메모→분류 제안
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
---
## 🔨 빌드 상태
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
---
## 다음 세션 재개 지점
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
+127
View File
@@ -0,0 +1,127 @@
# Play Console 등록 자료 — 돈돼지 가계부
> 첫 출시(무료 앱, 인앱결제·광고 모두 비활성화) 기준. Play Console 각 항목에 그대로 붙여넣기.
> 광고(카카오 애드핏)를 껐으므로 광고 식별자 수집/제3자 공유가 없어 데이터 안전이 단순함.
---
## 1. 스토어 등록정보 (Main store listing)
### 앱 이름 (30자 이내)
```
돈돼지 가계부
```
### 간단한 설명 / Short description (80자 이내)
```
가계부·자산·예산을 슬림하게. 고정지출 자동, 통계 차트, 영수증 인식까지.
```
### 자세한 설명 / Full description (4000자 이내)
```
🐷 돈돼지 가계부 — 슬림하게 관리하는 가계부 · 자산 · 예산
복잡한 가계부는 그만! 수입·지출을 빠르게 기록하고, 자산과 예산을 한눈에 관리하세요.
군더더기 없는 깔끔한 화면으로 매일 쓰기 부담 없는 가계부입니다.
▪ 간편한 가계부
· 수입·지출·이체를 몇 번의 터치로 기록
· 자주 쓰는 내역을 칩으로 등록해 1초 입력
· 월별 수입/지출/잔액 요약과 일별 내역 정리
▪ 자산 · 부채 관리
· 은행·현금·신용/체크카드·대출·투자 계좌를 한 곳에서
· 총자산 · 총부채 · 순자산을 자동 집계
▪ 고정 지출 자동 반영 (프리미엄)
· 월세·구독료·급여 등 반복 거래를 등록해두면 주기에 맞춰 자동 생성
· 실제 결제만 '확인'으로 확정 — 빠뜨림 없는 가계부
▪ 통계와 예산 (프리미엄)
· 분류별 지출, 수입 대비 지출, 지난달 대비, 월말 예상까지 차트로 분석
· 카테고리별 예산을 정하고 예산 대비 지출을 한눈에
▪ 영수증 인식 (프리미엄)
· 영수증을 촬영하면 금액·내역을 자동 인식해 입력 시간을 단축
▪ 커뮤니티
· 짠테크·재테크 노하우를 나누는 게시판
· 추천·댓글로 소통하고 좋은 글을 발견
▪ 그 외
· 다크 모드 지원
· 엑셀 백업/복구로 내 데이터를 안전하게 (프리미엄)
· 로그인 없이 '둘러보기'로 먼저 체험
무료로 핵심 가계부 기능을 모두 사용할 수 있으며, 통계·예산·고정지출 자동화·영수증 인식·백업 등은 프리미엄에서 제공됩니다. (구독 기능은 순차적으로 제공될 예정입니다.)
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
문의: sbsb401824@gmail.com
```
### 기타 등록정보 필드
- **앱 카테고리**: 금융 (Finance)
- **태그**: 가계부, 예산, 자산관리
- **연락처 이메일**: sbsb401824@gmail.com
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy
- **그래픽 에셋**:
- 앱 아이콘: brand/store/icon-512.png
- 그래픽 이미지(피처): brand/store/feature-1024x500-text.png (또는 -text 없는 버전)
- 휴대전화 스크린샷: brand/screenshots/shot1~6 (6장)
---
## 2. 데이터 안전 (Data safety) 양식
> "앱 콘텐츠 → 데이터 보안" 에서 작성. 아래 기준으로 답하면 개인정보처리방침과 일치함.
### 개요 질문
- 데이터를 수집하나요? → **예**
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR은 처리 위탁이며 별도 공유 아님)
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
### 수집하는 데이터 유형 (각 항목: 수집됨 / 목적 / 공유 여부)
| 데이터 유형 | 수집 | 공유 | 목적 |
|---|---|---|---|
| **개인 정보 → 이름** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **개인 정보 → 이메일 주소** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **금융 정보 → 기타 금융 정보** (사용자가 입력한 수입·지출·자산 등 가계부 데이터) | 예 | 아니요 | 앱 기능 |
| **사진 및 동영상 → 사진** (프로필·영수증·게시판 이미지) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 사용자가 생성한 콘텐츠** (게시판 글·댓글) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 앱 상호작용** | 예 | 아니요 | 앱 기능, 부정 이용 방지 |
각 항목 공통:
- **수집이 필수인가요?** → "데이터 수집이 필수임" (서비스 제공에 필요)
- **데이터가 암호화되나요(전송 중)?** → 예
- **삭제 요청 가능?** → 예
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
---
## 3. 콘텐츠 등급 설문 (IARC)
> "앱 콘텐츠 → 콘텐츠 등급" 설문. 핵심 답안:
- 앱 카테고리: **유틸리티/생산성/기타** (또는 금융)
- 폭력/성적/혐오/약물/도박 콘텐츠: **모두 없음**
- **사용자 간 상호작용/사용자 생성 콘텐츠(게시판)**: **있음** → "사용자가 콘텐츠를 만들고 공유할 수 있음" 으로 정직하게 표시
- 신고 기능(5건 누적 블라인드)·관리자 차단 등 **중재 수단 보유** → 관련 질문에 "콘텐츠 신고/차단 기능 있음"으로 답변
- 위치 공유 / 개인정보 공유 기능: **없음**
- 디지털 구매(인앱결제): 첫 출시는 **비활성화** → "없음" (추후 활성화 시 등급 갱신)
- **광고 포함 여부**: **아니요** (광고 껐음 — 추후 활성화 시 갱신)
- 예상 등급: 대체로 **만 3세 이상(전체이용가)** 수준, UGC로 인해 일부 지역에서 Teen 가능 — 설문 결과 따름
---
## 4. 기타 출시 전 체크
- **앱 액세스 권한**: 로그인 필요 → 심사용 테스트 계정 제공 (Play Console "앱 액세스 권한"에 kenneth73510 계정 또는 데모 안내). 데모(둘러보기)로도 핵심 화면 확인 가능함을 메모.
- **타겟 출시 국가**: 대한민국 (필요 시 확대)
- **데이터 삭제 URL**: https://www.sblog.kr/account-deletion (데이터 안전 양식 + 앱 콘텐츠)
- **앱 카테고리/연락처/방침 URL** 위 1번 참고
- 광고 포함 여부 질문: **아니요(광고 없음)** — 광고를 껐으므로 "광고 없음"으로 표시
- ⚠️ **개인정보처리방침 정합성**: 현재 방침 4항이 "무료 회원에게 광고가 표시되며…"로 적혀 있어 광고 OFF 와 불일치. 심사자 교차확인 대비해 방침의 광고 문구를 "향후 광고가 도입될 수 있으며…"(미래형)로 완화 권장. (원하면 수정·배포 도와드림)
```
+57
View File
@@ -0,0 +1,57 @@
# 프로덕션 액세스 신청서 초안 — 돈돼지 가계부
> 14일 폐쇄 테스트 완료 후 Play Console "프로덕션 액세스 신청" 폼에 입력.
> ⚠️ [대괄호] 부분은 실제 테스트 후 본인 경험으로 교체하세요. 거짓·과장 금지 — 실제로 테스트한 내용을 적어야 합니다.
---
## Q1. 테스터를 어떻게 모집했나요? (How did you recruit your testers?)
```
실제 Android 기기와 Google 계정을 보유한 [12]명의 테스터를 모집해 비공개 테스트를 진행했습니다.
테스터는 안드로이드 사용자 지인 및 개발자 커뮤니티의 베타 테스터 모임을 통해 모았으며,
모두 실제 기기에 앱을 설치하고 14일간 사용했습니다.
```
> 유료/품앗이 서비스를 쓴 경우에도 "실제 기기·실제 계정 테스터"라는 사실 위주로 적되, 허위로 쓰지 마세요.
## Q2. 테스터와 어떻게 소통하고 피드백을 수집했나요?
```
이메일과 메신저(오픈채팅)로 테스터에게 설치 안내와 점검 항목을 전달했습니다.
점검 항목: 회원가입/구글 로그인, 가계부 수입·지출 기록, 계좌·자산 관리, 예산·통계 화면,
고정지출 자동 반영, 커뮤니티 게시판(글·댓글·추천), 다크 모드.
테스터가 사용 중 발견한 버그·불편 사항을 회신받아 정리했습니다.
```
## Q3. 어떤 피드백을 받았고, 무엇을 개선했나요?
```
주요 피드백과 개선:
- 둘러보기(로그인 없이 체험) 모드에서 일부 화면의 메뉴 접근이 불편 → 메뉴 진입 동선 수정
- 특정 기기에서 한글 입력 시 자동완성/맞춤법 검사로 입력이 끊김 → 입력 필드 설정 수정으로 해결
- 무료/유료 기능 구분 및 화면 흐름이 헷갈린다는 의견 → 안내 문구·접근 동선 정리
- [본인 테스트 중 실제로 발견한 버그/불편과 그에 대한 수정 내용을 추가 기재]
크래시나 데이터 유실 등 치명적 문제는 발견되지 않았습니다.
```
## Q4. 앱이 프로덕션에 준비되었다고 보는 이유는?
```
돈돼지 가계부는 웹·Windows 데스크톱에서 이미 운영 중인 개인 가계부 서비스이며,
이번 비공개 테스트로 Android 실기기에서 핵심 기능(가계부 기록, 계좌·자산, 예산·통계,
고정지출 자동 반영, 커뮤니티, 구글 로그인)의 안정성을 14일간 검증했습니다.
다양한 기기에서 크래시 없이 정상 동작하며, 테스트 중 제기된 개선 사항을 반영했습니다.
개인정보처리방침·데이터 삭제 안내 등 정책 요건도 갖추어 정식 출시 준비가 되었다고 판단합니다.
```
---
## (선택) 영어 버전 — 폼이 영어로 표시될 경우
**Q1 Recruit:** I recruited [12] testers who own real Android devices with genuine Google accounts, through acquaintances and a developer beta-tester community. All installed the app and used it over the 14-day period.
**Q2 Feedback collection:** I communicated via email and messenger, sharing install instructions and a checklist (Google sign-in, expense/income tracking, accounts & assets, budget & statistics, recurring transactions, community board, dark mode), and collected bug reports and usability feedback.
**Q3 Findings & changes:** Key fixes included improving menu navigation in the guest "explore" mode, resolving a Korean-input (IME) issue on certain devices, and clarifying free/premium feature flow. [Add the real bugs you found.] No crashes or data-loss issues were found.
**Q4 Readiness:** Don Dwaeji budget app already runs in production on web and Windows desktop. The closed test validated the Android build's core features on real devices over 14 days with no crashes, and feedback was addressed. Privacy policy and data-deletion pages are in place, so the app is ready for production.
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+224 -1
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue' import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
@@ -9,6 +9,9 @@ import LoginModal from '@/components/LoginModal.vue'
import SignupModal from '@/components/SignupModal.vue' import SignupModal from '@/components/SignupModal.vue'
import WebOnlyNotice from '@/components/WebOnlyNotice.vue' import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
import AppDialog from '@/components/ui/AppDialog.vue' import AppDialog from '@/components/ui/AppDialog.vue'
import AppLockScreen from '@/components/AppLockScreen.vue'
import { appLock } from '@/composables/appLock'
import { appError, reportFatal } from '@/composables/appError'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -47,10 +50,72 @@ function onUnauthorized() {
function onPremiumRequired() { function onPremiumRequired() {
if (route.name !== 'upgrade') router.push({ name: 'upgrade' }) if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
} }
// ===== 네트워크 상태 배너 =====
// 오프라인이거나(서버 연결 끊김) 서버 응답이 없을 때 상단에 안내 배너를 띄운다.
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
const netTrouble = ref(false) // 온라인인데 서버 응답이 없는 경우(타임아웃/서버다운) 잠깐 표시
let netTimer = null
function onOnline() { online.value = true }
function onOffline() { online.value = false }
function onNetError() {
if (typeof navigator !== 'undefined' && !navigator.onLine) { online.value = false; return }
netTrouble.value = true
clearTimeout(netTimer)
netTimer = setTimeout(() => { netTrouble.value = false }, 5000)
}
const showNetBanner = computed(() => !online.value || netTrouble.value)
const netBannerText = computed(() =>
!online.value
? '📡 인터넷 연결이 끊겼어요. 연결 상태를 확인해 주세요.'
: '⚠️ 서버에 연결할 수 없어요. 잠시 후 다시 시도해 주세요.',
)
function retryReload() {
window.location.reload()
}
// ===== 앱 잠금(PIN) =====
// 잠깐 다른 앱 다녀온 정도로는 잠그지 않고, 일정 시간 이상 백그라운드였을 때만 잠근다.
// (완전 종료/재시작은 onMounted 에서 잠금) — 카메라/갤러리/공유 등 인앱 동작은 suppress 로 제외.
const LOCK_GRACE_MS = 60 * 60 * 1000 // 1시간 미만 이탈은 잠그지 않음(잦은 PIN 입력 방지)
let backgroundedAt = 0
function onVisibility() {
if (document.hidden) {
backgroundedAt = Date.now()
} else {
const away = backgroundedAt ? Date.now() - backgroundedAt : 0
backgroundedAt = 0
if (away >= LOCK_GRACE_MS) appLock.lock() // lock() 은 suppress 창이면 건너뜀
appLock.clearSuppress() // 복귀 — 일시중지 창 해제
}
}
// 파일 선택(input[type=file]) 클릭 시 다음 백그라운드 전환을 잠금에서 제외
function onFileInputClick(e) {
const t = e.target
if (t && t.tagName === 'INPUT' && t.type === 'file') appLock.suppressLock()
}
// ===== 토스트 (뒤로가기 종료 안내 등) =====
const toastMsg = ref('')
let toastTimer = null
function onToast(e) {
toastMsg.value = e.detail || ''
clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastMsg.value = '' }, 1800)
}
onMounted(() => { onMounted(() => {
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략 if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
window.addEventListener('auth:unauthorized', onUnauthorized) window.addEventListener('auth:unauthorized', onUnauthorized)
window.addEventListener('premium:required', onPremiumRequired) window.addEventListener('premium:required', onPremiumRequired)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('net:error', onNetError)
window.addEventListener('app:toast', onToast)
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
document.addEventListener('visibilitychange', onVisibility)
document.addEventListener('click', onFileInputClick, true)
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영) ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영 // 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
@@ -69,6 +134,20 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized) window.removeEventListener('auth:unauthorized', onUnauthorized)
window.removeEventListener('premium:required', onPremiumRequired) window.removeEventListener('premium:required', onPremiumRequired)
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('net:error', onNetError)
window.removeEventListener('app:toast', onToast)
document.removeEventListener('visibilitychange', onVisibility)
document.removeEventListener('click', onFileInputClick, true)
clearTimeout(netTimer)
clearTimeout(toastTimer)
})
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
onErrorCaptured((err, _instance, info) => {
reportFatal({ err, info })
return false // 상위로 전파 중단
}) })
// 페이지 이동 시 모바일 사이드바 자동 닫힘 // 페이지 이동 시 모바일 사이드바 자동 닫힘
@@ -76,6 +155,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
</script> </script>
<template> <template>
<!-- 치명적 오류 폴백 ( 화면 방지) -->
<div v-if="appError.hasError" class="fatal-screen">
<div class="fatal-inner">
<div class="fatal-emoji">😵</div>
<h1 class="fatal-title">문제가 발생했어요</h1>
<p class="fatal-sub">일시적인 오류일 있어요. 새로고침 다시 시도해 주세요.</p>
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
</div>
</div>
<template v-else>
<!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 --> <!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 -->
<template v-if="!isApp"> <template v-if="!isApp">
<RouterView v-if="isPublicLegal" /> <RouterView v-if="isPublicLegal" />
@@ -84,6 +174,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<!-- (Capacitor): 전체 기능 --> <!-- (Capacitor): 전체 기능 -->
<template v-else> <template v-else>
<!-- 잠금 화면 (로그인 상태 + 잠금 활성 ) -->
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
<!-- 네트워크 오류/오프라인 안내 배너 -->
<Transition name="netbar">
<div v-if="showNetBanner" class="net-banner" role="alert">
<span class="net-text">{{ netBannerText }}</span>
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
</div>
</Transition>
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }"> <div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
<AppHeader class="layout-top" /> <AppHeader class="layout-top" />
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) --> <!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) -->
@@ -108,10 +209,16 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<LoginModal /> <LoginModal />
<SignupModal /> <SignupModal />
<!-- 토스트 (뒤로가기 종료 안내 ) -->
<Transition name="toast">
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
</Transition>
</template> </template>
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) --> <!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
<AppDialog /> <AppDialog />
</template>
</template> </template>
<style scoped> <style scoped>
@@ -147,6 +254,122 @@ watch(() => route.fullPath, () => ui.closeSidebar())
background: hsla(160, 100%, 37%, 0.08); background: hsla(160, 100%, 37%, 0.08);
font-size: 0.88rem; font-size: 0.88rem;
} }
/* 네트워크 오류/오프라인 배너 — 화면 최상단 고정 */
.net-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: calc(env(safe-area-inset-top, 0) + 0.5rem) 0.9rem 0.5rem;
background: #c0392b;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
}
.net-text {
word-break: keep-all;
}
.net-retry {
flex: none;
padding: 0.25rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 6px;
background: transparent;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.net-retry:hover {
background: rgba(255, 255, 255, 0.15);
}
.netbar-enter-active,
.netbar-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.netbar-enter-from,
.netbar-leave-to {
transform: translateY(-100%);
opacity: 0;
}
/* 토스트 */
.app-toast {
position: fixed;
left: 50%;
bottom: calc(72px + env(safe-area-inset-bottom, 0));
transform: translateX(-50%);
z-index: 2600;
max-width: 80vw;
padding: 0.6rem 1.1rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.82);
color: #fff;
font-size: 0.85rem;
white-space: nowrap;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
/* 치명적 오류 폴백 화면 */
.fatal-screen {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--color-background);
}
.fatal-inner {
text-align: center;
max-width: 360px;
}
.fatal-emoji {
font-size: 2.8rem;
}
.fatal-title {
margin-top: 0.6rem;
font-size: 1.2rem;
color: var(--color-heading);
}
.fatal-sub {
margin-top: 0.5rem;
font-size: 0.9rem;
opacity: 0.7;
line-height: 1.6;
word-break: keep-all;
}
.fatal-btn {
margin-top: 1.4rem;
padding: 0.7rem 1.8rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.fatal-btn:hover {
background: hsla(160, 100%, 32%, 1);
}
.demo-actions { .demo-actions {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
+12 -4
View File
@@ -31,6 +31,10 @@ const realApi = {
parseText({ title, text } = {}) { parseText({ title, text } = {}) {
return http.post('/account/entries/parse', { title, text }) return http.post('/account/entries/parse', { title, text })
}, },
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') {
return http.get('/fx/rate', { params: { from, to } })
},
// 자주 쓰는 내역(빠른 등록 템플릿) // 자주 쓰는 내역(빠른 등록 템플릿)
quickEntries() { quickEntries() {
@@ -72,8 +76,12 @@ const realApi = {
}, },
// 예산 (사용자별) // 예산 (사용자별)
budgets() { budgets({ year, month } = {}) {
return http.get('/account/budgets') return http.get('/account/budgets', { params: { year, month } })
},
// 다른 월 예산을 이 달로 복사(전월 복사 등)
copyBudget({ fromYear, fromMonth, toYear, toMonth }) {
return http.post('/account/budgets/copy', null, { params: { fromYear, fromMonth, toYear, toMonth } })
}, },
budgetStatus({ year, month }) { budgetStatus({ year, month }) {
return http.get('/account/budgets/status', { params: { year, month } }) return http.get('/account/budgets/status', { params: { year, month } })
@@ -87,8 +95,8 @@ const realApi = {
setExpectedIncome({ year, month, expectedIncome }) { setExpectedIncome({ year, month, expectedIncome }) {
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } }) return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
}, },
createBudget(payload) { createBudget(payload, { year, month } = {}) {
return http.post('/account/budgets', payload) return http.post('/account/budgets', payload, { params: { year, month } })
}, },
updateBudget(id, payload) { updateBudget(id, payload) {
return http.put(`/account/budgets/${id}`, payload) return http.put(`/account/budgets/${id}`, payload)
+3
View File
@@ -34,6 +34,9 @@ http.interceptors.response.use(
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') { } else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내 // 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required')) window.dispatchEvent(new CustomEvent('premium:required'))
} else if (!error.response && error.code !== 'ERR_CANCELED') {
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
window.dispatchEvent(new CustomEvent('net:error'))
} }
return Promise.reject(error) return Promise.reject(error)
}, },
+3 -8
View File
@@ -1,18 +1,13 @@
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op. // Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app' import { App as CapApp } from '@capacitor/app'
import { handleBack } from '@/composables/appBack'
export function setupCapacitor(router) { export function setupCapacitor(router) {
if (!Capacitor.isNativePlatform()) return if (!Capacitor.isNativePlatform()) return
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료 // 안드로이드 하드웨어 뒤로가기 → 하단 내비 뒤로 버튼과 동일한 통합 처리
CapApp.addListener('backButton', ({ canGoBack }) => { CapApp.addListener('backButton', () => handleBack(router))
if (canGoBack && window.history.length > 1) {
router.back()
} else {
CapApp.exitApp()
}
})
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기) // 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
document.documentElement.classList.add('is-native') document.documentElement.classList.add('is-native')
+96
View File
@@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue'
import { appLock, PIN_LENGTH } from '@/composables/appLock'
import { useAuthStore } from '@/stores/auth'
import PinPad from '@/components/PinPad.vue'
const auth = useAuthStore()
const pin = ref('')
const error = ref(false)
async function onComplete(p) {
if (await appLock.verify(p)) {
pin.value = ''
error.value = false
appLock.unlock()
} else {
error.value = true
pin.value = ''
}
}
function onInput(v) {
pin.value = v
error.value = false
}
async function forgot() {
// PIN 분실 → 잠금 해제 + 로그아웃(재로그인 후 다시 설정). 갇힘 방지.
appLock.disable()
await auth.logout()
appLock.unlock()
}
</script>
<template>
<div class="lock-screen">
<div class="lock-inner">
<div class="lock-logo">🐷</div>
<h1 class="lock-title">돈돼지 가계부</h1>
<p class="lock-sub" :class="{ err: error }">
{{ error ? 'PIN이 일치하지 않아요' : 'PIN을 입력하세요' }}
</p>
<PinPad :value="pin" :length="PIN_LENGTH" class="lock-pad" @update:value="onInput" @complete="onComplete" />
<button type="button" class="lock-forgot" @click="forgot">PIN을 잊으셨나요? 로그아웃</button>
</div>
</div>
</template>
<style scoped>
.lock-screen {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: calc(env(safe-area-inset-top, 0) + 1rem) 1rem calc(env(safe-area-inset-bottom, 0) + 1rem);
background: var(--color-background);
}
.lock-inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.lock-logo {
font-size: 2.6rem;
}
.lock-title {
margin-top: 0.4rem;
font-size: 1.25rem;
color: var(--color-heading);
}
.lock-sub {
margin-top: 0.6rem;
margin-bottom: 1.8rem;
font-size: 0.92rem;
opacity: 0.7;
}
.lock-sub.err {
color: #c0392b;
opacity: 1;
font-weight: 600;
}
.lock-forgot {
margin-top: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.6;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.lock-forgot:hover {
opacity: 0.9;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
// 재사용 PIN 키패드 — 점 표시 + 숫자 키패드. 길이 도달 시 complete 방출.
const props = defineProps({
value: { type: String, default: '' },
length: { type: Number, default: 4 },
})
const emit = defineEmits(['update:value', 'complete'])
function press(d) {
if (props.value.length >= props.length) return
const next = props.value + d
emit('update:value', next)
if (next.length === props.length) emit('complete', next)
}
function del() {
emit('update:value', props.value.slice(0, -1))
}
</script>
<template>
<div class="pinpad">
<div class="dots">
<span v-for="i in length" :key="i" class="dot" :class="{ on: value.length >= i }"></span>
</div>
<div class="keys">
<button v-for="n in 9" :key="n" type="button" class="key" @click="press(String(n))">{{ n }}</button>
<span class="key empty" aria-hidden="true"></span>
<button type="button" class="key" @click="press('0')">0</button>
<button type="button" class="key del" aria-label="지우기" @click="del"></button>
</div>
</div>
</template>
<style scoped>
.pinpad {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6rem;
}
.dots {
display: flex;
gap: 0.9rem;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
opacity: 0.5;
}
.dot.on {
background: currentColor;
opacity: 1;
}
.keys {
display: grid;
grid-template-columns: repeat(3, 64px);
gap: 0.8rem;
}
.key {
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
font-size: 1.4rem;
font-weight: 600;
cursor: pointer;
}
.key:active {
background: var(--color-border);
}
.key.empty {
border: 0;
background: transparent;
cursor: default;
}
.key.del {
font-size: 1.2rem;
border: 0;
background: transparent;
}
</style>
+3 -3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { handleBack } from '@/composables/appBack'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -9,9 +10,8 @@ const isHome = computed(() => route.path === '/')
const isSettings = computed(() => route.path.startsWith('/settings')) const isSettings = computed(() => route.path.startsWith('/settings'))
function goBack() { function goBack() {
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게) // 하드웨어 뒤로가기와 동일 동작(오버레이 닫기 → 화면 뒤로 → 최상위 종료 안내)
if (window.history.length > 1) router.back() handleBack(router)
else router.push('/')
} }
function goForward() { function goForward() {
router.forward() router.forward()
+39
View File
@@ -0,0 +1,39 @@
import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app'
import { useUiStore } from '@/stores/ui'
import { appLock } from '@/composables/appLock'
// 통합 뒤로가기 처리 — 하드웨어 뒤로(capacitor.js)와 하단 내비 뒤로 버튼이 동일 동작.
// 우선순위:
// 1) 잠금 화면이면 우회 불가 → (네이티브) 종료
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
// 3) 화면 뒤로(히스토리)
// 4) 최상위에서 '한 번 더 누르면 종료'(네이티브만, 실수 종료 방지)
let exitArmed = false
let exitTimer = null
export function handleBack(router) {
const native = Capacitor.isNativePlatform()
const ui = useUiStore()
if (appLock.state.locked) {
if (native) CapApp.exitApp()
return
}
if (ui.signupOpen) { ui.closeSignup(); return }
if (ui.loginOpen) { ui.closeLogin(); return }
if (ui.passwordOpen) { ui.closePassword(); return }
if (ui.sidebarOpen) { ui.closeSidebar(); return }
if (window.history.length > 1) { router.back(); return }
// 최상위(더 갈 곳 없음)
if (native) {
if (exitArmed) { CapApp.exitApp(); return }
exitArmed = true
window.dispatchEvent(new CustomEvent('app:toast', { detail: '한 번 더 누르면 종료됩니다' }))
clearTimeout(exitTimer)
exitTimer = setTimeout(() => { exitArmed = false }, 2000)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { reactive } from 'vue'
// 전역 치명적 오류 상태. 컴포넌트 트리 밖에서 관리해, 렌더 오류로 화면이
// 깨져도(흰 화면) App.vue 가 폴백 UI 를 대신 렌더할 수 있게 한다.
export const appError = reactive({ hasError: false })
export function reportFatal(err) {
// 콘솔에는 원본을 남기고, 사용자에겐 폴백 화면을 보여준다.
// eslint-disable-next-line no-console
console.error('[fatal]', err)
appError.hasError = true
}
export function clearFatal() {
appError.hasError = false
}
+67
View File
@@ -0,0 +1,67 @@
import { reactive } from 'vue'
// 앱 잠금(PIN). 자체 구현 — 네이티브 플러그인 없이 동작.
// PIN 은 평문 저장하지 않고 SHA-256 해시만 보관. 기본 비활성(옵트인).
// 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃' 으로 disable + 로그아웃 가능.
const KEY_ENABLED = 'applock:enabled'
const KEY_HASH = 'applock:hash'
export const PIN_LENGTH = 4
const state = reactive({
enabled: localStorage.getItem(KEY_ENABLED) === '1',
locked: false,
})
// 카메라/갤러리/공유 등 앱 내 네이티브 동작을 호출하면 웹뷰가 잠깐 백그라운드로
// 인식돼 잠금이 걸리는 오작동을 막기 위한 "잠금 일시중지" 창(timestamp).
let suppressUntil = 0
async function hashPin(pin) {
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
const buf = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
}
export const appLock = {
state,
/** PIN 설정 + 잠금 활성화 */
async setPin(pin) {
localStorage.setItem(KEY_HASH, await hashPin(pin))
localStorage.setItem(KEY_ENABLED, '1')
state.enabled = true
},
/** 입력 PIN 검증 */
async verify(pin) {
const stored = localStorage.getItem(KEY_HASH)
return !!stored && (await hashPin(pin)) === stored
},
/** 잠금 해제(설정 끄기) */
disable() {
localStorage.removeItem(KEY_HASH)
localStorage.removeItem(KEY_ENABLED)
state.enabled = false
state.locked = false
},
/** 잠금 걸기(활성화 + 일시중지 창 밖일 때만) */
lock() {
if (Date.now() < suppressUntil) return // 네이티브 동작 복귀 — 잠그지 않음
if (state.enabled) state.locked = true
},
/** 다음 백그라운드 전환을 잠그지 않음(카메라/갤러리/공유 호출 직전에 사용) */
suppressLock(ms = 60000) {
suppressUntil = Date.now() + ms
},
/** 일시중지 창 해제(앱으로 복귀 시) */
clearSuppress() {
suppressUntil = 0
},
/** 잠금 풀기(이번 세션) */
unlock() {
state.locked = false
},
/** 저장된 세션이 있는지(앱 시작 시 잠금 여부 판단용) */
hasSession() {
return !!localStorage.getItem('token')
},
}
+6
View File
@@ -8,6 +8,7 @@ import router from './router'
import { setupCapacitor } from './capacitor' import { setupCapacitor } from './capacitor'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useDialog } from './composables/dialog' import { useDialog } from './composables/dialog'
import { reportFatal } from './composables/appError'
import { initTheme } from './theme' import { initTheme } from './theme'
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지 // 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
@@ -22,6 +23,11 @@ window.alert = (message) => {
const app = createApp(App) const app = createApp(App)
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
app.config.errorHandler = (err, instance, info) => {
reportFatal({ err, info })
}
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
+4 -1
View File
@@ -2,6 +2,7 @@
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다. // 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { appLock } from '@/composables/appLock'
async function safe(promise) { async function safe(promise) {
try { try {
@@ -25,6 +26,7 @@ async function saveWorkbook(XLSX, wb, name) {
const { Share } = await import('@capacitor/share') const { Share } = await import('@capacitor/share')
const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache }) const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache })
try { try {
appLock.suppressLock() // 공유 시트 복귀 시 앱 잠금 오작동 방지
await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri }) await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri })
} catch { } catch {
/* 공유 취소 — 파일은 캐시에 저장됨 */ /* 공유 취소 — 파일은 캐시에 저장됨 */
@@ -42,7 +44,8 @@ export async function exportBackup() {
safe(accountApi.wallets()), safe(accountApi.wallets()),
safe(accountApi.categories()), safe(accountApi.categories()),
safe(accountApi.recurrings()), safe(accountApi.recurrings()),
safe(accountApi.budgets()), // 예산은 월별 — 백업엔 현재 월 예산을 담는다
safe(accountApi.budgets({ year: new Date().getFullYear(), month: new Date().getMonth() + 1 })),
safe(accountApi.tags()), safe(accountApi.tags()),
safe(accountApi.quickEntries()), safe(accountApi.quickEntries()),
]) ])
+123
View File
@@ -39,6 +39,21 @@ const budgetPct = computed(() =>
const budgetLeft = computed(() => budgetTotal.value - spentTotal.value) const budgetLeft = computed(() => budgetTotal.value - spentTotal.value)
const budgetOver = computed(() => budgetLeft.value < 0) const budgetOver = computed(() => budgetLeft.value < 0)
// ===== 신규 유저 시작하기 안내 =====
// 데이터가 전혀 없으면(이번 달 내역 0 + 자산/부채 0) 첫 사용 가이드를 노출.
// 한 번 닫으면 다시 보이지 않도록 로컬에 기록.
const isEmpty = computed(() =>
entries.value.length === 0 &&
networth.value.totalAssets === 0 &&
networth.value.totalLiabilities === 0,
)
const gsDismissed = ref(localStorage.getItem('home:gettingStarted:dismissed') === '1')
const showGettingStarted = computed(() => !loading.value && !error.value && isEmpty.value && !gsDismissed.value)
function dismissGettingStarted() {
gsDismissed.value = true
try { localStorage.setItem('home:gettingStarted:dismissed', '1') } catch { /* 무시 */ }
}
function won(n) { function won(n) {
return (n ?? 0).toLocaleString('ko-KR') return (n ?? 0).toLocaleString('ko-KR')
} }
@@ -166,6 +181,30 @@ onMounted(load)
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<!-- 신규 유저 시작하기 안내 (데이터 없을 때만) -->
<section v-if="showGettingStarted" class="getting-started">
<button type="button" class="gs-close" aria-label="안내 닫기" @click="dismissGettingStarted">×</button>
<h2 class="gs-title">가계부를 시작해볼까요? 🐷</h2>
<p class="gs-sub">아래 순서로 기록을 시작해보세요.</p>
<div class="gs-steps">
<RouterLink to="/account/wallets" class="gs-step">
<span class="gs-num">1</span>
<span class="gs-text"><b>계좌 등록하기</b><span>은행·카드·현금 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/categories" class="gs-step">
<span class="gs-num">2</span>
<span class="gs-text"><b>기본 분류 불러오기</b><span>식비·교통 카테고리 준비</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/entries" class="gs-step">
<span class="gs-num">3</span>
<span class="gs-text"><b> 내역 기록하기</b><span>수입·지출을 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
</div>
</section>
<div class="cards"> <div class="cards">
<!-- 이번 수입/지출/수지 --> <!-- 이번 수입/지출/수지 -->
<div class="card"> <div class="card">
@@ -346,6 +385,90 @@ onMounted(load)
opacity: 0.65; opacity: 0.65;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* 신규 유저 시작하기 안내 */
.getting-started {
position: relative;
margin: 1.2rem 0;
padding: 1.1rem 1.2rem 1.2rem;
border: 1px solid hsla(160, 100%, 37%, 0.35);
border-radius: 14px;
background: hsla(160, 100%, 37%, 0.06);
}
.gs-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.5;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.gs-close:hover {
opacity: 0.9;
}
.gs-title {
font-size: 1.1rem;
color: var(--color-heading);
}
.gs-sub {
margin-top: 0.25rem;
font-size: 0.86rem;
opacity: 0.7;
}
.gs-steps {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gs-step {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background);
color: var(--color-text);
text-decoration: none;
}
.gs-step:hover {
border-color: hsla(160, 100%, 37%, 0.6);
}
.gs-num {
flex: none;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.82rem;
font-weight: 700;
}
.gs-text {
display: flex;
flex-direction: column;
}
.gs-text b {
font-size: 0.92rem;
}
.gs-text span {
font-size: 0.78rem;
opacity: 0.65;
}
.gs-arrow {
margin-left: auto;
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.cards { .cards {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
+420 -55
View File
@@ -1,9 +1,10 @@
<script setup> 1<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { appLock } from '@/composables/appLock'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr' import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { cardNotif } from '@/native/cardNotif' import { cardNotif } from '@/native/cardNotif'
@@ -83,13 +84,99 @@ function resetFilters() {
// 추가/수정 모달 // 추가/수정 모달
const formOpen = ref(false) const formOpen = ref(false)
const editId = ref(null) const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' }) const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
// ===== 외화 결제 =====
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
const isForeign = computed(() => form.currency && form.currency !== 'KRW')
// JPY 등은 원본이 정수 위주라 100단위 표기가 흔하지만, 환율은 1단위 기준으로 통일
const convertedKrw = computed(() =>
isForeign.value ? Math.round((Number(form.foreignAmount) || 0) * (Number(form.rate) || 0)) : null,
)
// 외화면 환산 원화를 amount 에 반영(저장·검증·표시는 원화 기준)
watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
if (isForeign.value) form.amount = convertedKrw.value
})
const fxLoading = ref(false)
async function fetchRate() {
if (!isForeign.value || fxLoading.value) return
fxLoading.value = true
try {
const res = await accountApi.fxRate(form.currency, 'KRW')
// 환율은 소수점 4자리로 통일(입력폼 step·저장 정밀도 일치)
if (res?.rate != null) form.rate = Math.round(Number(res.rate) * 10000) / 10000
else formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
} catch {
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
} finally {
fxLoading.value = false
}
}
function onCurrencyChange() {
if (isForeign.value) {
if (form.foreignAmount == null && form.amount != null) form.foreignAmount = form.amount // 원화→외화 전환 시 초기값
fetchRate()
} else {
form.foreignAmount = null
form.rate = null
}
}
const isRepayment = computed(() => form.type === 'REPAYMENT') const isRepayment = computed(() => form.type === 'REPAYMENT')
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음) // 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
const repayTargetIsCard = computed(() => { const repayTargetIsCard = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId) const w = wallets.value.find((x) => x.id === form.toWalletId)
return !!w && w.type === 'CARD' return !!w && w.type === 'CARD'
}) })
// 금리가 설정된 대출 계좌를 선택한 경우 자동계산 활성
const repayTargetLoanWallet = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return w?.type === 'LOAN' && w.loanRate ? w : null
})
// 납입금액 입력용 (원리금균등만 사용)
const loanPaymentAmount = ref(null)
function monthlyInterestOf(loan) {
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
}
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
const loanAutoResult = computed(() => {
const loan = repayTargetLoanWallet.value
if (!loan) return null
const interest = monthlyInterestOf(loan)
if (loan.loanMethod === 'BULLET') {
return { interest, principal: 0, total: interest, needsInput: false }
}
if (loan.loanMethod === 'EQUAL_PRINCIPAL' && loan.loanAmount && loan.loanMonths) {
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
return { interest, principal, total: principal + interest, needsInput: false }
}
return { interest: null, principal: null, total: null, needsInput: true }
})
watch(loanAutoResult, (r) => {
if (r && !r.needsInput) {
form.interest = r.interest
form.principal = r.principal
loanPaymentAmount.value = r.total
}
})
function calcLoanRepayment(payment) {
const loan = repayTargetLoanWallet.value
if (!loan || !payment || payment <= 0) return
const interest = monthlyInterestOf(loan)
form.interest = Math.min(interest, payment)
form.principal = Math.max(0, payment - form.interest)
}
watch(loanPaymentAmount, (val) => {
if (loanAutoResult.value?.needsInput && val) calcLoanRepayment(Number(val))
})
watch(() => form.toWalletId, () => {
loanPaymentAmount.value = null
form.principal = null
form.interest = null
})
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값) // 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD') const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => { const installmentMonthly = computed(() => {
@@ -136,6 +223,7 @@ async function chooseGallery() {
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드 // 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
async function captureFrom(sourceKey) { async function captureFrom(sourceKey) {
try { try {
appLock.suppressLock() // 카메라 복귀 시 앱 잠금 오작동 방지
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera') const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
const photo = await Camera.getPhoto({ const photo = await Camera.getPhoto({
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos, source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
@@ -209,6 +297,8 @@ const WALLET_KINDS = [
] ]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind)) const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind)) const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
function walletKindOf(id) { function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id) const w = wallets.value.find((x) => x.id === id)
return w ? w.type : '' return w ? w.type : ''
@@ -236,25 +326,39 @@ async function loadCategories() {
categories.value = [] categories.value = []
} }
} }
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존 // ===== 분류: 대분류 아코디언 → 소분류 선택 =====
// ===== 분류: 대분류 → 소분류 2단 선택 (긴 드롭다운 스크롤 방지) ===== const categoryMajor = ref('') // 펼쳐진 대분류 이름
const categoryMajor = ref('') // 선택된 대분류 이름 // 현재 구분의 대분류 객체 목록
// 현재 구분의 대분류(부모 없음) 이름 + 편집 중 현재 대분류 보존 const majorOptionsData = computed(() =>
const majorOptions = computed(() => { categories.value.filter((c) => c.type === form.type && c.parentId == null)
const names = categories.value )
.filter((c) => c.type === form.type && c.parentId == null) // 대분류를 4개씩 행으로 묶음
.map((c) => c.name) const majorRows = computed(() => {
if (categoryMajor.value && !names.includes(categoryMajor.value)) names.unshift(categoryMajor.value) const items = majorOptionsData.value
return names const rows = []
}) for (let i = 0; i < items.length; i += 4) rows.push(items.slice(i, i + 4))
// 선택된 대분류의 소분류 이름 목록 (없으면 대분류 자체가 분류) return rows
const subOptions = computed(() => {
const major = categories.value.find(
(c) => c.type === form.type && c.parentId == null && c.name === categoryMajor.value,
)
if (!major) return []
return categories.value.filter((c) => c.parentId === major.id).map((c) => c.name)
}) })
// 현재 펼쳐진 대분류 객체
const selectedMajorObj = computed(() =>
majorOptionsData.value.find((m) => m.name === categoryMajor.value) ?? null
)
// 특정 대분류의 소분류 목록
function subsByMajor(majorId) {
return categories.value.filter((c) => c.parentId === majorId)
}
// 대분류 클릭: 소분류 있으면 펼침 토글, 없으면 바로 선택
function onMajorClick(major) {
if (categoryMajor.value === major.name) {
categoryMajor.value = ''
form.category = ''
} else {
categoryMajor.value = major.name
if (!subsByMajor(major.id).length) form.category = major.name
else form.category = ''
}
}
// form.category 로부터 대분류 추론 (편집 진입 시) // form.category 로부터 대분류 추론 (편집 진입 시)
function syncCategoryMajor() { function syncCategoryMajor() {
const cur = form.category const cur = form.category
@@ -270,19 +374,25 @@ function syncCategoryMajor() {
categoryMajor.value = cur categoryMajor.value = cur
} }
} }
// 대분류 변경: 소분류 있으면 소분류 선택 대기 / 없으면 대분류가 곧 분류
function onMajorChange() {
form.category = subOptions.value.length ? '' : categoryMajor.value
}
// 필터용 전체 분류명 (중복 제거) // 필터용 전체 분류명 (중복 제거)
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))]) const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
// 모달 내 분류 인라인 추가 // 모달 내 분류 인라인 추가
const addingCategory = ref(false) const addingCategory = ref(false)
const addingAsMajor = ref(true) // true=대분류 추가, false=소분류 추가
const newCategoryName = ref('') const newCategoryName = ref('')
const catSubmitting = ref(false) const catSubmitting = ref(false)
function startAddCategory() { function startAddMajorCategory() {
categoryMajor.value = ''
form.category = ''
newCategoryName.value = '' newCategoryName.value = ''
addingAsMajor.value = true
addingCategory.value = true
}
function startAddSubCategory() {
newCategoryName.value = ''
addingAsMajor.value = false
addingCategory.value = true addingCategory.value = true
} }
function cancelAddCategory() { function cancelAddCategory() {
@@ -450,7 +560,7 @@ function todayStr() {
function openCreate() { function openCreate() {
editId.value = null editId.value = null
editingPending.value = false editingPending.value = false
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' }) Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
selectedTagIds.value = [] selectedTagIds.value = []
syncCategoryMajor() syncCategoryMajor()
cancelAddCategory() cancelAddCategory()
@@ -479,6 +589,9 @@ function openEdit(e) {
interest: null, interest: null,
annualFee: null, annualFee: null,
installmentMonths: e.installmentMonths || '', installmentMonths: e.installmentMonths || '',
currency: e.currency && e.currency !== 'KRW' ? e.currency : 'KRW',
foreignAmount: e.currency && e.currency !== 'KRW' ? Number(e.originalAmount) : null,
rate: e.currency && e.currency !== 'KRW' ? Number(e.exchangeRate) : null,
}) })
// 태그 이름 → id 매핑 (현재 태그 목록 기준) // 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {} const nameToId = {}
@@ -557,6 +670,7 @@ async function submit() {
} }
} }
submitting.value = true submitting.value = true
const foreign = isForeign.value
const payload = { const payload = {
entryDate: form.entryDate, entryDate: form.entryDate,
type: form.type, type: form.type,
@@ -567,6 +681,10 @@ async function submit() {
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null, toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null, installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
tagIds: selectedTagIds.value, tagIds: selectedTagIds.value,
// 외화 결제: amount 는 환산 원화, 원본은 별도 보존
currency: foreign ? form.currency : null,
originalAmount: foreign ? Number(form.foreignAmount) : null,
exchangeRate: foreign ? Number(form.rate) : null,
} }
try { try {
if (editId.value) { if (editId.value) {
@@ -945,7 +1063,8 @@ onMounted(async () => {
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span> </span>
</div> </div>
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2"> <div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length) || (e.currency && e.currency !== 'KRW')" class="ei-line2">
<span v-if="e.currency && e.currency !== 'KRW'" class="ei-fx">{{ e.currency }} {{ Number(e.originalAmount).toLocaleString('ko-KR') }} @{{ Number(e.exchangeRate).toLocaleString('ko-KR') }}</span>
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span> <span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span> <span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span> <span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
@@ -954,13 +1073,19 @@ onMounted(async () => {
</ul> </ul>
</section> </section>
</div> </div>
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : ' 달의 내역이 없습니다.' }}</p> <p v-else-if="!loading && hasFilter" class="msg">조건에 맞는 내역이 없습니다.</p>
<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>
<!-- 추가/수정 모달 --> <!-- 추가/수정 모달 -->
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false"> <div v-if="formOpen" class="modal-backdrop entry-backdrop">
<div class="modal" role="dialog" aria-modal="true"> <div class="modal entry-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button> <button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2> <h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
@@ -1016,9 +1141,9 @@ onMounted(async () => {
</div> </div>
</div> </div>
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting"> <select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드 선택' : '출금 계좌 선택') }}</option> <option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option>
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id"> <option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }} {{ w.name || w.issuer }}
</option> </option>
</select> </select>
</div> </div>
@@ -1044,7 +1169,7 @@ onMounted(async () => {
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting"> <select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
<option value="">입금 계좌 선택</option> <option value="">입금 계좌 선택</option>
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id"> <option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }} {{ w.name || w.issuer }}
</option> </option>
</select> </select>
</div> </div>
@@ -1056,38 +1181,97 @@ onMounted(async () => {
<select v-model="form.toWalletId" :disabled="submitting"> <select v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option> <option value="">(선택)</option>
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id"> <option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }} {{ w.name || w.issuer }}
</option> </option>
</select> </select>
</label> </label>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label> <!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label> <template v-if="repayTargetLoanWallet">
<!-- 원리금균등: 납입금액 직접 입력 -->
<template v-if="loanAutoResult?.needsInput">
<label>납입금액(원리금균등)
<input v-model.number="loanPaymentAmount" type="number" min="0"
placeholder="이번 달 납입할 총금액" :disabled="submitting" />
</label>
</template>
<!-- 원금균등·만기일시: 자동계산 결과 표시 -->
<template v-else>
<div class="loan-breakdown">
<span class="bd-label">{{ repayTargetLoanWallet.loanMethod === 'BULLET' ? '만기일시' : '원금균등' }} 자동계산</span>
<span>납입 <b>{{ (loanAutoResult?.total || 0).toLocaleString('ko-KR') }}</b></span>
</div>
</template>
<!-- 계산 결과 표시 + 수동 조정 -->
<div v-if="(form.interest ?? null) !== null || (form.principal ?? null) !== null" class="loan-breakdown">
<span>이자 <b>{{ (form.interest || 0).toLocaleString('ko-KR') }}</b></span>
<span>원금 <b>{{ (form.principal || 0).toLocaleString('ko-KR') }}</b></span>
</div>
<label>이자(조정)<input v-model.number="form.interest" type="number" min="0" :disabled="submitting" /></label>
<label>원금(조정)<input v-model.number="form.principal" type="number" min="0" :disabled="submitting" /></label>
</template>
<template v-else>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
</template>
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label> <label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
</template> </template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류 <label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<div v-if="!addingCategory" class="cat-input cascade"> <div v-if="!addingCategory" class="cat-picker">
<select v-model="categoryMajor" :disabled="submitting" @change="onMajorChange" title="대분류"> <!-- 대분류 행별 렌더링: 선택된 대분류가 속한 바로 아래 소분류 삽입 -->
<option value="">대분류 선택</option> <template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
<option v-for="m in majorOptions" :key="m" :value="m">{{ m }}</option> <div class="acc-major-grid">
</select> <button
<select v-if="subOptions.length" v-model="form.category" :disabled="submitting" title="소분류"> v-for="m in row" :key="m.id"
<option value="">소분류 선택</option> type="button" class="acc-chip major"
<option v-for="s in subOptions" :key="s" :value="s">{{ s }}</option> :class="{ active: categoryMajor === m.name }"
</select> :disabled="submitting"
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button> @click="onMajorClick(m)"
>{{ m.name }}</button>
</div>
<div v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id) && subsByMajor(selectedMajorObj.id).length" class="acc-sub-grid">
<button
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
type="button" class="acc-chip sub"
:class="{ active: form.category === s.name }"
:disabled="submitting"
@click="form.category = s.name"
>{{ s.name }}</button>
<button type="button" class="acc-chip add-chip sub-add" :disabled="submitting" @click="startAddSubCategory">+ 소분류</button>
</div>
</template>
<button type="button" class="acc-chip add-chip" :disabled="submitting" @click="startAddMajorCategory">+ 대분류</button>
</div> </div>
<div v-else class="cat-input"> <div v-else class="cat-input">
<input <input
v-model="newCategoryName" type="text" v-model="newCategoryName" type="text"
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'" :placeholder="addingAsMajor ? (form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류') : `${categoryMajor} > 새 소분류`"
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory" :disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
/> />
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button> <button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button> <button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
</div> </div>
</label> </label>
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label> <label v-if="!isRepayment" class="amount-label">
금액
<div class="amount-row">
<select v-model="form.currency" class="cur-sel" :disabled="submitting" @change="onCurrencyChange">
<option v-for="c in CURRENCIES" :key="c" :value="c">{{ c }}</option>
</select>
<input v-if="!isForeign" v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" />
<input v-else v-model.number="form.foreignAmount" type="number" min="0" step="0.01" :placeholder="form.currency + ' 금액'" :disabled="submitting" />
</div>
</label>
<div v-if="!isRepayment && isForeign" class="fx-row">
<div class="fx-rate-field">
<span class="fx-label">환율(1 {{ form.currency }})</span>
<div class="fx-rate-input">
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button>
</div>
</div>
<span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
</div>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label> <label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment && isPremium" class="tag-field"> <div v-if="!isRepayment && isPremium" class="tag-field">
@@ -1515,6 +1699,39 @@ button.primary {
.msg { .msg {
margin: 1rem 0; margin: 1rem 0;
} }
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.4rem 1rem 2rem;
}
.empty-emoji {
font-size: 2.6rem;
}
.empty-title {
margin-top: 0.6rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
}
.empty-desc {
margin-top: 0.3rem;
font-size: 0.88rem;
opacity: 0.7;
}
.empty-cta {
margin-top: 1.1rem;
padding: 0.7rem 1.6rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
.msg.error { .msg.error {
color: #c0392b; color: #c0392b;
} }
@@ -1556,6 +1773,27 @@ button.primary {
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.6; opacity: 0.6;
} }
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
.entry-backdrop {
padding: 0;
}
.modal.entry-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
padding-top: calc(1.5rem + env(safe-area-inset-top));
}
/* 넓은 화면(PC)에서도 입력 내용은 읽기 좋은 폭으로 가운데 정렬 */
.modal.entry-modal > h2,
.modal.entry-modal .entry-form {
max-width: 460px;
margin-left: auto;
margin-right: auto;
}
.modal { .modal {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -1743,13 +1981,6 @@ button.primary {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
} }
.cat-input.cascade {
flex-wrap: wrap;
}
.cat-input.cascade select {
flex: 1 1 40%;
}
.cat-input select,
.cat-input input { .cat-input input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1764,6 +1995,78 @@ button.primary {
border-color: hsla(160, 100%, 37%, 1); border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
} }
/* 분류 칩 그리드 */
.cat-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.acc-major-grid,
.acc-sub-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.acc-sub-grid {
padding: 0.4rem;
border-radius: 6px;
background: var(--color-background-soft);
}
.acc-chip {
flex: 1 1 calc(25% - 0.35rem);
min-width: 0;
padding: 0.38rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.82rem;
color: var(--color-text);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.acc-chip.major.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.acc-chip.sub {
background: var(--color-background);
}
.acc-chip.sub.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.acc-chip.add-chip {
opacity: 0.55;
border-style: dashed;
}
.acc-chip.add-chip.sub-add {
flex: 0 0 auto;
}
.loan-breakdown {
display: flex;
gap: 1rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
background: var(--color-background-soft);
font-size: 0.85rem;
color: var(--color-text);
}
.loan-breakdown b {
color: hsla(160, 100%, 37%, 1);
}
.loan-breakdown .bd-label {
width: 100%;
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.1rem;
}
.tag-field { .tag-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1812,6 +2115,68 @@ button.primary {
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
white-space: nowrap; white-space: nowrap;
} }
/* 외화 표기(목록 상세) */
.ei-fx {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(40, 90%, 50%, 0.6);
border-radius: 3px;
color: #b8860b;
white-space: nowrap;
}
/* 입력폼 금액/환율 */
.amount-row {
display: flex;
gap: 0.4rem;
}
.cur-sel {
flex: none;
width: 5rem;
}
.amount-row input {
flex: 1;
min-width: 0;
}
.fx-row {
display: flex;
align-items: flex-end;
gap: 0.6rem;
flex-wrap: wrap;
margin: -0.3rem 0 0.2rem;
}
.fx-rate-field {
display: flex;
flex-direction: column;
}
.fx-label {
font-size: 0.8rem;
margin-bottom: 0.2rem;
}
/* input 과 버튼을 한 행으로 — stretch 로 버튼 높이를 input 에 맞춤 */
.fx-rate-input {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.fx-rate-input input {
width: 7rem;
}
.fx-auto {
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.fx-krw {
font-weight: 700;
color: #b8860b;
font-size: 0.9rem;
padding-bottom: 0.45rem;
}
.row-wallet { .row-wallet {
margin-right: 0.35rem; margin-right: 0.35rem;
font-size: 0.75rem; font-size: 0.75rem;
+96 -2
View File
@@ -33,6 +33,12 @@ const form = reactive({
openingBalance: 0, openingBalance: 0,
openingDate: '', openingDate: '',
currentValue: null, currentValue: null,
// 대출(LOAN) 전용
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
}) })
const submitting = ref(false) const submitting = ref(false)
const formError = ref(null) const formError = ref(null)
@@ -196,7 +202,7 @@ function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관' return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
} }
function openingLabel(t) { function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)' return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
} }
// 투자 수익률(%) — 투입원금 대비 평가손익 // 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) { function returnPct(w) {
@@ -229,6 +235,11 @@ function openCreate() {
openingBalance: 0, openingBalance: 0,
openingDate: '', openingDate: '',
currentValue: null, currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
}) })
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
@@ -245,6 +256,11 @@ function openEdit(w) {
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0, openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '', openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null, currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
}) })
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
@@ -258,6 +274,7 @@ async function submit() {
} }
submitting.value = true submitting.value = true
const magnitude = Number(form.openingBalance) || 0 const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = { const payload = {
type: form.type, type: form.type,
name: form.name.trim(), name: form.name.trim(),
@@ -271,6 +288,11 @@ async function submit() {
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue) ? Number(form.currentValue)
: null, : null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
} }
try { try {
if (editId.value) await accountApi.updateWallet(editId.value, payload) if (editId.value) await accountApi.updateWallet(editId.value, payload)
@@ -297,6 +319,9 @@ async function remove(w) {
function cardTypeLabel(v) { function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : '' return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
} }
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
onMounted(async () => { onMounted(async () => {
await load() await load()
@@ -362,6 +387,10 @@ onBeforeUnmount(() => sortable?.destroy())
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button> >{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template> </template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template> <template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span>
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
</template>
</span> </span>
</div> </div>
<div class="balance-wrap"> <div class="balance-wrap">
@@ -421,7 +450,12 @@ onBeforeUnmount(() => sortable?.destroy())
</div> </div>
</li> </li>
</ul> </ul>
<p v-if="!loading && !rows.length" class="msg">등록된 항목이 없습니다.</p> <div v-if="!loading && !rows.length" 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>
<!-- 추가/수정 모달 --> <!-- 추가/수정 모달 -->
<Teleport to="body"> <Teleport to="body">
@@ -453,6 +487,33 @@ onBeforeUnmount(() => sortable?.destroy())
</template> </template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label> <label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
placeholder="예: 5.25" :disabled="submitting" />
</label>
<label>상환방식
<select v-model="form.loanMethod" :disabled="submitting">
<option value="">(선택 )</option>
<option value="EQUAL_PAYMENT">원리금균등상환</option>
<option value="EQUAL_PRINCIPAL">원금균등상환</option>
<option value="BULLET">만기일시상환</option>
</select>
</label>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p> <p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons"> <div class="buttons">
@@ -795,6 +856,39 @@ button.primary {
.msg { .msg {
margin: 1rem 0; margin: 1rem 0;
} }
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.4rem 1rem 2rem;
}
.empty-emoji {
font-size: 2.6rem;
}
.empty-title {
margin-top: 0.6rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
}
.empty-desc {
margin-top: 0.3rem;
font-size: 0.88rem;
opacity: 0.7;
}
.empty-cta {
margin-top: 1.1rem;
padding: 0.7rem 1.6rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
.msg.error { .msg.error {
color: #c0392b; color: #c0392b;
} }
+68 -2
View File
@@ -127,7 +127,7 @@ async function load() {
error.value = null error.value = null
try { try {
const params = { year: year.value, month: month.value } const params = { year: year.value, month: month.value }
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()]) const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets(params)])
statuses.value = st statuses.value = st
budgetsRaw.value = raw budgetsRaw.value = raw
} catch (e) { } catch (e) {
@@ -154,6 +154,46 @@ function nextMonth() {
loadIncome() loadIncome()
} }
// ===== 월별 예산 복사 =====
const copying = ref(false)
const ym = (y, m) => `${y}.${String(m).padStart(2, '0')}`
async function copyToNextMonth() {
if (copying.value || !budgetsRaw.value.length) return
const to = month.value === 12 ? { y: year.value + 1, m: 1 } : { y: year.value, m: month.value + 1 }
const ok = await dialog.confirm(
`${ym(year.value, month.value)} 예산을 ${ym(to.y, to.m)}(다음 달)로 복사할까요?\n다음 달의 같은 분류 예산은 덮어씁니다.`,
{ title: '다음 달로 복사' },
)
if (!ok) return
copying.value = true
try {
await accountApi.copyBudget({ fromYear: year.value, fromMonth: month.value, toYear: to.y, toMonth: to.m })
await dialog.alert(`${ym(to.y, to.m)}로 예산을 복사했어요.`)
} catch (e) {
error.value = e.response?.data?.message || '복사에 실패했습니다.'
} finally {
copying.value = false
}
}
async function copyFromPrevMonth() {
if (copying.value) return
const prev = month.value === 1 ? { y: year.value - 1, m: 12 } : { y: year.value, m: month.value - 1 }
const ok = await dialog.confirm(
`전월(${ym(prev.y, prev.m)}) 예산을 ${ym(year.value, month.value)}로 가져올까요?\n이 달의 같은 분류 예산은 덮어씁니다.`,
{ title: '전월 예산 복사' },
)
if (!ok) return
copying.value = true
try {
await accountApi.copyBudget({ fromYear: prev.y, fromMonth: prev.m, toYear: year.value, toMonth: month.value })
await load()
} catch (e) {
error.value = e.response?.data?.message || '복사에 실패했습니다.'
} finally {
copying.value = false
}
}
function openCreate() { function openCreate() {
editId.value = null editId.value = null
Object.assign(form, { Object.assign(form, {
@@ -213,7 +253,7 @@ async function submit() {
submitting.value = true submitting.value = true
try { try {
if (editId.value) await accountApi.updateBudget(editId.value, payload) if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload) else await accountApi.createBudget(payload, { year: year.value, month: month.value })
formOpen.value = false formOpen.value = false
await load() await load()
} catch (e) { } catch (e) {
@@ -252,6 +292,12 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 월별 예산 복사 -->
<div class="copy-actions">
<button type="button" class="copy-btn" :disabled="copying" @click="copyFromPrevMonth"> 전월 예산 가져오기</button>
<button type="button" class="copy-btn" :disabled="copying || !budgetsRaw.length" @click="copyToNextMonth">다음 달로 복사 </button>
</div>
<!-- 예상 수입 vs 예산 --> <!-- 예상 수입 vs 예산 -->
<div class="income-panel"> <div class="income-panel">
<div class="inc-input"> <div class="inc-input">
@@ -431,6 +477,26 @@ button.primary {
text-align: center; text-align: center;
} }
/* 예상 수입 vs 예산 */ /* 예상 수입 vs 예산 */
.copy-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
margin: -0.4rem 0 1rem;
}
.copy-btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.copy-btn:disabled {
opacity: 0.45;
cursor: default;
}
.income-panel { .income-panel {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
+75 -8
View File
@@ -62,15 +62,22 @@ async function reload() {
} }
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) ===== // ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
// 스크롤하다 실수로 순서가 바뀌는 것 방지: 기본은 드래그 비활성, '순서변경' 버튼으로 켠다.
const majorListEl = ref(null) const majorListEl = ref(null)
const reorderMode = ref(false)
let sortables = [] let sortables = []
function destroySortables() { function destroySortables() {
sortables.forEach((s) => s.destroy()) sortables.forEach((s) => s.destroy())
sortables = [] sortables = []
} }
function toggleReorder() {
reorderMode.value = !reorderMode.value
nextTick(() => initSortable()) // 켜면 생성, 끄면 destroy 후 early-return
}
function initSortable() { function initSortable() {
destroySortables() destroySortables()
if (!majorListEl.value) return if (!majorListEl.value || !reorderMode.value) return
// 대분류 순서 — 블록 단위라 소분류가 함께 이동 // 대분류 순서 — 블록 단위라 소분류가 함께 이동
sortables.push( sortables.push(
Sortable.create(majorListEl.value, { Sortable.create(majorListEl.value, {
@@ -189,7 +196,12 @@ onBeforeUnmount(destroySortables)
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button> <button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button> <button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
</div> </div>
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button> <div class="tabs-actions">
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
<button type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
{{ reorderMode ? '완료' : '순서변경' }}
</button>
</div>
</div> </div>
<form class="new-cat" @submit.prevent="addCategory"> <form class="new-cat" @submit.prevent="addCategory">
@@ -202,7 +214,10 @@ onBeforeUnmount(destroySortables)
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" /> <IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
</div> </div>
</form> </form>
<p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다.</p> <p class="hint sm">
<template v-if="reorderMode"> 손잡이를 끌어 순서를 바꾸세요 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동. 끝나면 <b>완료</b>.</template>
<template v-else><b>순서변경</b> 누르면 분류 순서를 바꿀 있어요. (평소엔 스크롤 실수 방지로 잠겨 있어요)</template>
</p>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
@@ -211,7 +226,7 @@ onBeforeUnmount(destroySortables)
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block"> <li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
<div class="cat-row major-row"> <div class="cat-row major-row">
<div class="cat-main"> <div class="cat-main">
<span class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span> <span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" /> <input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" /> <IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
@@ -220,7 +235,7 @@ onBeforeUnmount(destroySortables)
<ul class="sub-list" :data-parent="g.major.id"> <ul class="sub-list" :data-parent="g.major.id">
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child"> <li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
<div class="cat-main"> <div class="cat-main">
<span class="drag-handle sub-handle" title="소분류 순서 변경"></span> <span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경"></span>
<span class="sub-mark"></span> <span class="sub-mark"></span>
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" /> <input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" /> <IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
@@ -230,9 +245,12 @@ onBeforeUnmount(destroySortables)
</ul> </ul>
</li> </li>
</ul> </ul>
<p v-if="!loading && !tree.length" class="msg"> <div v-if="!loading && !tree.length" class="empty-state">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다. <p class="empty-emoji">🗂</p>
</p> <p class="empty-title">등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요</p>
<p class="empty-desc">기본 분류를 불러오면 식비·교통 등이 번에 추가됩니다.</p>
<button type="button" class="empty-cta" @click="importDefaults">기본 분류 불러오기</button>
</div>
<!-- 인앱 확인 모달 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) --> <!-- 인앱 확인 모달 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<Teleport to="body"> <Teleport to="body">
@@ -299,9 +317,23 @@ button.danger {
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
} }
.tabs-row .tabs { .tabs-row .tabs {
margin-bottom: 0; margin-bottom: 0;
flex: none; /* 탭은 줄어들지 않음 */
}
.tabs-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.reorder-btn.active {
color: #fff;
background: hsla(160, 100%, 37%, 1);
border-radius: 6px;
} }
.tabs { .tabs {
display: flex; display: flex;
@@ -315,6 +347,7 @@ button.danger {
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
white-space: nowrap; /* '지출 분류' 두 줄로 깨짐 방지 */
} }
.tabs button.active { .tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1); border-bottom-color: hsla(160, 100%, 37%, 1);
@@ -411,6 +444,7 @@ button.danger {
.sub-mark { .sub-mark {
opacity: 0.45; opacity: 0.45;
} }
.hint.sm { .hint.sm {
font-size: 0.78rem; font-size: 0.78rem;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
@@ -421,6 +455,39 @@ button.danger {
.msg.error { .msg.error {
color: #c0392b; color: #c0392b;
} }
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.2rem 1rem 2rem;
}
.empty-emoji {
font-size: 2.6rem;
}
.empty-title {
margin-top: 0.6rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
}
.empty-desc {
margin-top: 0.3rem;
font-size: 0.88rem;
opacity: 0.7;
}
.empty-cta {
margin-top: 1.1rem;
padding: 0.7rem 1.6rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
/* 인앱 확인 모달 */ /* 인앱 확인 모달 */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
+118
View File
@@ -7,6 +7,8 @@ import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { exportBackup, importBackup } from '@/utils/backup' import { exportBackup, importBackup } from '@/utils/backup'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { appLock, PIN_LENGTH } from '@/composables/appLock'
import PinPad from '@/components/PinPad.vue'
const dialog = useDialog() const dialog = useDialog()
const ui = useUiStore() const ui = useUiStore()
@@ -61,6 +63,46 @@ async function changeNotifHour(v) {
if (notifEnabled.value) await reminders.scheduleEntryReminder(false) if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
} }
// ===== 앱 잠금(PIN) =====
const lockEnabled = computed(() => appLock.state.enabled)
const lockSetupOpen = ref(false)
const setupStep = ref(1) // 1: 입력, 2: 확인
const setupPin = ref('')
const setupFirst = ref('')
const setupError = ref('')
function toggleLock() {
if (lockEnabled.value) confirmDisableLock()
else openLockSetup()
}
function openLockSetup() {
setupStep.value = 1
setupPin.value = ''
setupFirst.value = ''
setupError.value = ''
lockSetupOpen.value = true
}
async function onSetupComplete(p) {
if (setupStep.value === 1) {
setupFirst.value = p
setupPin.value = ''
setupStep.value = 2
setupError.value = ''
} else if (p === setupFirst.value) {
await appLock.setPin(p)
lockSetupOpen.value = false
} else {
setupError.value = 'PIN이 일치하지 않아요. 처음부터 다시 설정해주세요.'
setupStep.value = 1
setupPin.value = ''
setupFirst.value = ''
}
}
async function confirmDisableLock() {
const ok = await dialog.confirm('앱 잠금을 끌까요? 다음 실행부터 PIN 없이 열립니다.', { title: '앱 잠금 해제' })
if (ok) appLock.disable()
}
const exporting = ref(false) const exporting = ref(false)
async function doExport() { async function doExport() {
if (exporting.value) return if (exporting.value) return
@@ -198,6 +240,21 @@ async function clearAppData() {
</div> </div>
</section> </section>
<!-- 잠금 (PIN) -->
<section class="card">
<div class="row">
<div class="row-main">
<span class="row-label"> 잠금 (PIN)</span>
<span class="row-sub"> 실행·복귀 {{ PIN_LENGTH }}자리 PIN 입력</span>
</div>
<button
type="button" class="switch" :class="{ on: lockEnabled }"
role="switch" :aria-checked="lockEnabled"
@click="toggleLock"
><span class="knob"></span></button>
</div>
</section>
<!-- PC(데스크톱) 전용: 크기 (가로·세로 각각) --> <!-- PC(데스크톱) 전용: 크기 (가로·세로 각각) -->
<section v-if="isDesktop" class="card"> <section v-if="isDesktop" class="card">
<div class="row"> <div class="row">
@@ -295,6 +352,20 @@ async function clearAppData() {
</section> </section>
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p> <p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
<!-- PIN 설정 모달 -->
<Teleport to="body">
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
<div class="pin-modal" role="dialog" aria-modal="true" aria-label="PIN 설정">
<button type="button" class="pin-close" aria-label="닫기" @click="lockSetupOpen = false">×</button>
<h2 class="pin-h">{{ setupStep === 1 ? 'PIN 설정' : 'PIN 다시 입력' }}</h2>
<p class="pin-sub" :class="{ err: setupError }">
{{ setupError || (setupStep === 1 ? `사용할 ${PIN_LENGTH}자리 PIN을 입력하세요` : '확인을 위해 한 번 더 입력하세요') }}
</p>
<PinPad :value="setupPin" :length="PIN_LENGTH" @update:value="(v) => { setupPin = v; setupError = '' }" @complete="onSetupComplete" />
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
@@ -303,6 +374,53 @@ async function clearAppData() {
max-width: 560px; max-width: 560px;
margin: 0 auto; margin: 0 auto;
} }
/* PIN 설정 모달 */
.pin-backdrop {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
}
.pin-modal {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.6rem 1.2rem 1.8rem;
border-radius: 14px;
background: var(--color-background);
border: 1px solid var(--color-border);
text-align: center;
}
.pin-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.4rem;
cursor: pointer;
}
.pin-h {
font-size: 1.1rem;
color: var(--color-heading);
}
.pin-sub {
margin: 0.4rem 0 1.4rem;
font-size: 0.85rem;
opacity: 0.7;
}
.pin-sub.err {
color: #c0392b;
opacity: 1;
font-weight: 600;
}
.page-title { .page-title {
font-size: 1.4rem; font-size: 1.4rem;
margin-bottom: 1rem; margin-bottom: 1rem;