Compare commits

...

91 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
ByungCheol 7633bdc166 docs: 개인정보처리방침 광고 문구 정합화 (광고 OFF 반영)
CI / build (push) Failing after 11m57s
- 광고를 첫 출시에서 비활성화함에 따라 방침의 광고 관련 문구를
  현재 상태(광고 없음) + 미래형(향후 도입 가능)으로 수정
- 4항: '광고가 표시되며' → '현재 광고 미표시, 향후 도입 시…'
- 수집/목적/제3자(카카오) 항목도 '향후 도입 시' 조건부로 완화
- Play 데이터 안전 양식(광고 식별자 미수집)과 정합성 확보

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:29:31 +09:00
ByungCheol 9d60ea8f12 feat: 첫 출시 광고(카카오 애드핏) 비활성화
CI / build (push) Failing after 12m3s
- ADS_ENABLED=false 플래그 추가(features.js)
- 광고단위 미승인 + 데이터 안전 양식 단순화(광고 식별자 수집/제3자 공유 제거)
- AdBanner showAd + App.vue showAds 양쪽 게이팅 → 앱/웹 광고 모두 미노출,
  레이아웃 하단 광고 여백도 함께 제거
- 애드핏 승인 후 true 로 되돌리면 무료 회원 광고 재노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:24:25 +09:00
ByungCheol ee5e9d4adb fix: 둘러보기(데모) 모드에서 메뉴(사이드바) 열기 불가 수정
CI / build (push) Failing after 14m1s
- 사이드바 메뉴 항목은 데모에서도 렌더되지만, 여는 햄버거 버튼이
  로그인 전용(v-if=isAuthenticated)이라 모바일에서 메뉴에 도달 불가했음
- 햄버거 조건에 demo.on 추가 → 데모에서도 가계부/계좌/분류/고정지출 이동 가능

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:57:28 +09:00
ByungCheol 36178572e1 chore(brand): 스토어 그래픽 에셋 생성기 + 산출물
- brand/gen-store-assets.mjs: 1024 마스터에서 스토어 이미지 생성(sharp)
- brand/logo-1024.png: 돈돼지 마스터 아트(1024x1024) 백업
- brand/store/: icon-512, feature-1024x500(텍스트 유/무) — Play Console 업로드용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:26:10 +09:00
ByungCheol a2cda9b4ee feat: 첫 출시 무료 앱 — 인앱결제(구독) 진입점 비활성화
CI / build (push) Failing after 10m51s
- BILLING_ENABLED=false 플래그 추가(features.js)
- 실연동 전 구매 흐름이 테스트 스텁이라, 활성화 시 결제 없이 프리미엄이
  부여되는 문제가 있어 구매 진입점을 모두 숨김
- UpgradeView: 구매 버튼 대신 '구독 곧 출시 예정' 안내
- AccountInfoView: 정기결제 관리 카드 + 업그레이드 링크 미노출
- 무료 기능은 정상 동작, 실 Google Play Billing 연동은 다음 업데이트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:20:14 +09:00
ByungCheol e8d3549f02 feat: 계정삭제 안내 페이지 + 카드알림(알림접근) 첫 출시 비활성화
CI / build (push) Failing after 13m7s
- /account-deletion 공개 페이지(웹 접근) — Play 데이터 삭제 정책 URL, 웹 안내 하단 링크
- CARD_NOTIF_ENABLED=false: 카드알림 자동인식 UI 숨김
- AndroidManifest: CardNotifListenerService 선언 주석 처리(알림접근 정책 회피)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:43:22 +09:00
ByungCheol 4838eba9af chore(ads): 앱 배너를 320x50 모바일 단위(임시)로, 웹은 728x90 유지
CI / build (push) Failing after 14m11s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:29:10 +09:00
ByungCheol 29ff09a8d6 feat: 내 글·댓글 모아보기 화면 + 사이드바 메뉴
CI / build (push) Failing after 12m7s
- MyBoardView(탭: 내 글/내 댓글, 페이징, 글로 이동), /my-board 라우트
- 사이드바 게시판 영역에 내 글·댓글 링크, 헤더 타이틀

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:26:16 +09:00
ByungCheol 8be7072614 feat: 댓글 신고 블라인드 관리자 해제 버튼
CI / build (push) Failing after 11m47s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:17:11 +09:00
ByungCheol 59f38b3348 feat: 게시판 신고 UI — 글/댓글 신고 버튼 + 블라인드 표시
CI / build (push) Failing after 12m42s
- 작성자·관리자 외 신고 버튼(글 액션·각 댓글)
- 신고 누적 블라인드 댓글: 비관리자 가림, 관리자만 본문 + 신고 블라인드 뱃지
- 신고 후 새로고침으로 상태 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:05:43 +09:00
ByungCheol 0fa8c95139 chore(android): local-notifications 플러그인 동기화(cap sync)
CI / build (push) Failing after 11m31s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:43:24 +09:00
ByungCheol 127551da12 feat: 로컬 알림 — 가계부 기록 리마인더 + 구독 만료 임박 (앱 전용)
CI / build (push) Failing after 14m29s
- @capacitor/local-notifications 추가, src/native/reminders.js
- 매일 설정 시각(기본 21시) 가계부 미기록 시 알림(오늘 기록하면 오늘자 제외, 7일 롤링)
- 구독 해지 상태 만료 3일 전 알림
- 설정에 알림 받기 토글 + 시간 선택(앱에서만), 권한 요청
- 앱 시작/홈/내역에서 스케줄 보정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:42:00 +09:00
ByungCheol fa3b12b369 feat: 계정정보 구매 복원 + 기프트콘 교환(업데이트 예정)
CI / build (push) Failing after 13m41s
- 정기결제 관리에 구매 복원 버튼(billingApi.restore)
- 포인트 옆 기프트콘 교환 항목(업데이트 예정 안내)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:29:30 +09:00
ByungCheol db68ea1680 build(android): 릴리스 서명 구성 + AAB - key.properties 기반 서명
CI / build (push) Failing after 14m30s
- key.properties(있으면) 로 릴리스 서명, 없으면 디버그 폴백
- 키스토어/key.properties 는 gitignore (커밋 금지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:16:59 +09:00
ByungCheol e8dc6b5568 feat: 개인정보처리방침/이용약관 공개 페이지 + 보강 (스토어 정책)
CI / build (push) Failing after 11m53s
- terms.js 보강: 구글 로그인·인앱결제·광고·OCR·제3자·탈퇴·문의 반영
- /privacy, /terms 공개 라우트 + LegalView (웹 브라우저에서도 접근 = 앱 마켓 URL)
- 웹 안내 페이지 하단·설정 화면에 정책 링크 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:09:35 +09:00
ByungCheol d243600f41 feat: 계정정보에 회원 탈퇴 — 2단계 확인 후 계정·데이터 삭제
CI / build (push) Failing after 11m31s
- authApi.withdraw(), 강한 경고 + 2차 확인 → DELETE /auth/me → 세션 정리·홈 이동

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:59:25 +09:00
ByungCheol d7639225f7 feat: 카카오 애드핏 광고 — 웹 안내 하단 + 앱 하단내비 위 (유료 제외)
CI / build (push) Successful in 36s
- AdBanner 컴포넌트(애드핏 DAN-n0I5XRBOwAdSFbEX, 728x90), 유료 회원에겐 미표시
- 웹 안내 페이지 하단 배치
- 앱 셸 하단 내비 바로 위 고정 배치(본문 하단 여백 보정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:38:07 +09:00
ByungCheol 1000bf06af fix(board): 수정 화면도 게시판별 태그만 — 단, 글이 가진 기존 태그는 보존
CI / build (push) Failing after 15m27s
- 새글·수정 모두 boardApi.tagGroups(category)로 이 게시판 매핑 그룹만 노출
- 수정 시 전체 그룹으로 기존 태그 id 해석 → 이 게시판에 없는 태그는 별도 배지로 보존
- 다른 게시판 태그가 수정 화면에 모두 뜨던 문제 해결

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:14:43 +09:00
ByungCheol 821aec72b9 feat: 계정정보 정기결제 관리 — 현황·해지/재개·플랜 변경
CI / build (push) Failing after 13m36s
- 결제 플랜/만료일/다음 결제일 표시
- 구독 해지(안내 후 자동갱신 중단, 만료일까지 이용)/재개
- 결제 가능한 플랜 목록 + 변경/구독(Android 결제, PC는 앱 안내)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:04:41 +09:00
ByungCheol 0dda0b6ccd fix: 게시판 목록 태그 필터를 게시판별로 로드(게시판 전환 시 갱신)
CI / build (push) Failing after 13m31s
- boardApi.tags(category), 게시판 변경 시 태그 필터 재로드
- 매핑된 그룹의 태그만 필터 칩으로 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:44:40 +09:00
ByungCheol 09634757f7 style: 태그 관리 - 미매핑 그룹 안내 갱신(미노출), bm-none 표기
CI / build (push) Failing after 13m44s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:24:27 +09:00
ByungCheol 823bdcafcf feat: 업그레이드 화면 인앱 결제 — Android 구매, PC는 앱 유도
CI / build (push) Failing after 15m11s
- billingApi(products/verifyGoogle), native/billing(테스트 구매 토큰 생성)
- Android 앱: 상품(월간/연간) 구매 버튼 → 검증 → 프로필 갱신
- PC(웹/데스크톱): 구매 대신 앱 다운로드 안내
- 프리미엄 이용 중이면 만료일 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:12:59 +09:00
ByungCheol ce5cefe64a style: 분류 화면 대분류 대 뱃지 제거 (분류 관리·기본 분류)
CI / build (push) Failing after 15m33s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:50:24 +09:00
ByungCheol 56d2ad6a8a feat: 계정정보 포인트 클릭 시 적립내역 모달
CI / build (push) Failing after 13m30s
- 포인트 행 클릭 → 적립/차감 내역 모달(사유·일시·증감)
- authApi.pointHistory(), 사유 라벨(BOARD_WRITE=게시판 작성)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:44:59 +09:00
ByungCheol 7e096d5e7b refactor: board_setting 제거에 따른 정리 + 수정 시 태그 유실 방지
CI / build (push) Failing after 12m59s
- adminApi getBoardSetting/setBoardSetting 제거
- 글 수정 화면은 전체 그룹 로드(기존 글 태그 유지), 새 글은 게시판 매핑 그룹만

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:05:12 +09:00
ByungCheol c167875567 feat: 관리자 태그 화면 - 그룹별 게시판 매핑 체크박스
CI / build (push) Failing after 13m38s
- 전역 단일 카테고리 선택 제거 → 각 태그 그룹에 사용 게시판 체크박스(커뮤니티/짠테크/재테크 팁)
- 저장 시 boards 함께 전송, 선택 없으면 전체 게시판
- 글쓰기 화면은 현재 게시판에 매핑된 그룹 태그만 노출(boardApi.tagGroups(category))

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:34:40 +09:00
ByungCheol 7e38eae2b7 feat: 게시판 인기/블라인드/자기글 차단·헤더 아바타·댓글 새로고침 등
CI / build (push) Failing after 11m35s
- 헤더에 내 아바타(계정정보 링크) + 앱 시작 시 프로필 동기화(재로그인 없이 아바타/포인트)
- 목록: 인기 글 상단 배지(🔥), 비추천 20+ 블라인드 표시
- 상세: 본인 글/댓글 추천·비추천 비활성, 비추천 20+ 글/댓글 블라인드(그래도 보기)
- 댓글: 인기 댓글 배지, 본문 작성자에 작성자 뱃지, 목록 하단 새로고침 버튼(입력폼 위)
- 추천자 목록 가로 줄바꿈(아바타+이름 단위, 이름 안 잘리게)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:21:37 +09:00
ByungCheol 7699cfc55f refine(account): 프로필 사진 - 되돌리기 버튼을 삭제로 변경
CI / build (push) Failing after 12m47s
- 구글사진으로/기본으로 버튼 제거, 삭제 버튼으로 단순화
- 등록한 사진 삭제 시 구글 계정 아바타로 표시(없으면 이니셜)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:48:43 +09:00
ByungCheol 1636c35aef refine(board): 목록 추천 열·상세 추천수·추천자 인라인·아바타 축소
CI / build (push) Failing after 11m2s
- 목록: 제목 옆 추천 표시 제거 → 작성일 옆 추천 열로 표기(모바일도 라벨)
- 상세: 메타에 조회수 옆 추천수 표기
- 추천자: 클릭 시 모달 대신 하단에 아바타+이름 목록 펼침(토글)
- 아바타 크기를 본문 폰트 크기에 맞춰 16px 로 축소(목록/상세/댓글/추천자)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:45:27 +09:00
ByungCheol 206364d2f1 fix(board): 상세→목록 이동 시 페이지·검색조건 유지
CI / build (push) Failing after 14m43s
- 목록 행 클릭 시 현재 쿼리(page/tag/keyword/searchType)를 상세로 전달
- 상세 목록 버튼·삭제 후 이동도 같은 쿼리로 복귀 → 1페이지 초기화 방지
  (브라우저 뒤로가기는 기존에도 URL 쿼리로 복원됨)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:36:48 +09:00
ByungCheol ca66c1d53e feat: 게시판 추천/비추천·작성자 아바타·추천자·포인트 (프론트)
CI / build (push) Failing after 13m27s
- UserAvatar 컴포넌트(커스텀>구글>이니셜, 로드 실패 시 이니셜 폴백)
- 목록: 작성자 아바타 + 추천 수 표기
- 상세: 작성자 아바타, 추천/비추천 토글 버튼(내 투표 강조),
  추천자 본문 하단 아바타 10개 + 초과 시 (+N명이 추천했습니다), 클릭 시 전체 목록 모달
- 댓글: 작성자 아바타 + 추천/비추천 버튼
- 계정정보: 포인트 표기(GET /auth/points 최신값)
- boardApi.votePost/voteComment/recommenders, authApi.points 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:28:20 +09:00
ByungCheol 7855298eac feat: 계정정보 프로필 사진 — 구글 아바타 우선 + 변경/되돌리기
- 아바타 표시 우선순위: 사용자 지정(profileImage) > 구글(googlePicture) > 이니셜
- 사진 변경: 이미지 선택 → 정사각형 크롭·192px 축소(JPEG) → PUT /auth/profile-image
- 되돌리기: 사용자 지정 사진 해제 → 구글 사진(없으면 이니셜)으로 폴백
- authApi.updateProfileImage 추가, utils/avatar.js(표시 소스/이니셜/다운스케일)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:39:48 +09:00
ByungCheol 24ab91b257 fix(electron): 중복 실행 방지 + 로딩 스플래시 — 재시작 빈 화면 해소
- 단일 인스턴스 잠금(requestSingleInstanceLock): 두 번째 실행 시 새 창 대신 기존 창 활성화
  (중복 실행 시 라이브 사이트를 다시 로드하며 흰 화면이 오래 보이던 현상 해결)
- 시작 시 전체 캐시 비우기 제거 → index.html 만 캐시버스트(?_=t), 해시 자산은 캐시 유지(빠른 재시작)
- 로드되는 동안 스플래시(로딩) 창 표시, ready-to-show 후 메인 창 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:25:00 +09:00
ByungCheol 866abee860 fix: 무료 회원 가계부 내역 진입 시 유료 안내로 튕기는 버그
- onMounted 의 runRecurrings(고정지출 자동반영)는 유료 전용 경로(/recurrings/run)라
  무료 회원이 403 → premium:required → /upgrade 로 리다이렉트되던 문제
- isPremium 일 때만 runRecurrings 호출, '이 내역을 고정지출로 등록' 버튼도 유료에만 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:24:59 +09:00
ByungCheol 9861ba90ae feat: 멤버십(무료/유료) 게이팅 — 프론트
CI / build (push) Failing after 15m5s
- auth 스토어 isPremium/isAdmin 추가 (plan===PREMIUM 또는 관리자)
- 사이드바: 통계·고정지출·예산·태그 메뉴에 무료 회원 잠금 배지(🔒)
- 라우트 가드: 유료 전용 화면(requiresPremium) → 무료 회원은 /upgrade
- UpgradeView: 무료/유료 기능 비교 + 업그레이드 안내
- http 인터셉터: 403 PREMIUM_REQUIRED → premium:required 이벤트 → 업그레이드 화면
- 무료 화면의 유료 API 호출 차단(홈 예산·내역 태그·OCR·카드알림 게이팅)
- 회원관리: 멤버십 컬럼 + 무료/유료 토글(관리자)
- 설정: 데이터 백업/복구 유료 게이팅, 계정정보에 멤버십 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:02:44 +09:00
ByungCheol 2755d118c2 fix(electron): 기본 메뉴(File/Edit/View) 전역 제거 — 구글 로그인 팝업 메뉴바 제거
Menu.setApplicationMenu(null) 로 메인·자식 창 모두 메뉴바 비표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:40:18 +09:00
ByungCheol c9b58f4ad0 ci: main 푸시 시 CI 중복 실행 제거(Deploy만) — 배포 시간 단축
CI / build (push) Successful in 38s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:22:48 +09:00
ByungCheol 9aa21696d9 fix: 백업/복구 - 계좌 ID 컬럼 추가, 내역/고정/자주내역을 계좌ID 기준으로
CI / build (push) Failing after 11m48s
- 내보내기: 계좌 시트 ID 컬럼, 내역/고정지출/자주내역에 계좌ID·입금계좌ID
- 가져오기: 계좌ID 기준 파싱 (이름 충돌 무관 정확 복구)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:03:24 +09:00
ByungCheol 7d8f947156 feat: 설정 - 엑셀 가져오기(복구) + 창 크기 가로·세로 분리 조절
CI / build (push) Failing after 12m18s
- backup.js importBackup: .xlsx 파싱 → 이름 기준 payload → POST /account/restore
- SettingsView: '가져오기' 파일 선택 + 파괴적 작업 확인(이중) + 완료 후 새로고침
- accountApi.restore (데모에선 차단)
- 창 크기: 가로/세로 각각 선택(세로 700~1200), 한 축 변경 시 나머지 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:38:38 +09:00
ByungCheol 457fd8127a feat: 설정 - 엑셀 데이터 내보내기(백업)
CI / build (push) Successful in 44s
- utils/backup.js: 가계부 전체(내역·계좌·분류·고정지출·예산·태그·자주내역)를
  이름 기준 참조로 .xlsx 다중 시트 생성. xlsx 동적 import(별도 청크)
- 웹/PC=다운로드, 앱(네이티브)=Filesystem 저장 + Share
- SettingsView: '데이터 백업' 카드(엑셀로 내보내기 동작 / 가져오기 버튼=준비중)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:14:19 +09:00
ByungCheol 4ffc01f484 feat: 둘러보기(데모)를 실제 화면 그대로 — 데모 모드 + API 더미 응답
- src/demo: demo.on 플래그 + DTO 형태 더미데이터 + mock API(읽기 더미/쓰기 차단)
- accountApi: Proxy 로 데모 시 읽기→더미, 쓰기→안내. 실제 뷰 그대로 렌더
- App: authed=인증||데모 → 실제 사이드바/셸 표시 + 데모 배너(로그인/나가기)
- 라우터: 데모 허용(가계부/고정/계좌/분류) 외 보호화면은 DemoLockedView(로그인 유도)
- 사이드바: 데모 시 잠금(🔒) 배지, 메뉴는 showMenu(인증||데모)
- 로그인/로그아웃 시 데모 자동 해제, 랜딩 '둘러보기'→enterDemo
- 기존 독립 DemoView 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:49:37 +09:00
ByungCheol 80e1faec09 docs: 멤버십(무료/유료) 메뉴·기능 구분 설계 (추후 구현)
- 계좌 개수 제한 없음, 핵심 기록 무료
- 유료: OCR·카드알림 자동인식·통계·예산·고정지출·투자·태그·백업

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:52:02 +09:00
ByungCheol cee5b661e7 feat: 로그인 없이 둘러보기(데모) — 가계부/고정지출/계좌/분류 더미 + 나머지 로그인 유도
CI / build (push) Failing after 11m36s
- DemoView: 자체 메뉴로 섹션 전환, 4개 화면 샘플 데이터,
  통계·예산·태그·커뮤니티는 잠금(로그인 유도) 안내
- 공개 라우트 /demo, 랜딩에 '로그인 없이 둘러보기' 버튼, 헤더 타이틀 '둘러보기'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:42:48 +09:00
ByungCheol 54a285a3b6 docs: 변경 내역 갱신 — 구글 로그인·다크모드·UI 개편 (2026-06-28)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:12:59 +09:00
ByungCheol e4e1be49ca feat: PC 화면 해상도 변경 — 설정에서 창 크기 프리셋 선택
CI / build (push) Failing after 14m21s
- electron/preload.cjs: contextBridge 로 window.desktop.setWindowSize 노출
- main.cjs: preload 연결 + ipcMain 'window:setSize'/'getSize' 핸들러
- SettingsView: PC(window.desktop 존재 시)에서 해상도 선택 추가
  (1024×720 ~ 1920×1080), 선택 시 창 크기 변경+가운데 정렬

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:57:41 +09:00
ByungCheol f34980048b feat: 계정정보 단순화 + 구글 계정 이름 변경 허용
- 계정정보: 아이디·가입유형 행 제거 (이름/이메일/권한만), '이름 변경' 버튼
- ProfileEdit: 소셜(구글) 계정은 비밀번호 재인증 없이 바로 이름만 변경
  (아이디/이메일 편집/비밀번호 변경 섹션은 LOCAL 전용으로 숨김)
- 백엔드 updateProfile 은 provider 무관하게 동작(변경 없음)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:53:32 +09:00
ByungCheol 85670a1b9f fix: PC 레이아웃 — 사이드바를 좌측 상단까지 확장, 헤더는 우측 영역만
- App.vue 그리드 'top top' → 'left top' (사이드바가 헤더 행까지 차지)
- 사이드바 브랜드 영역 높이 56px 로 헤더와 정렬(보더 일치)
- 모바일은 기존 드로어 구조 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:49:02 +09:00
ByungCheol 5bfa45ed30 feat: 자주 쓰는 내역 UX 정리 + 빠른등록 견고화
- 등록 모달의 '자주 쓰는 내역 불러오기' 버튼·피커 모달 제거(번거로운 3단계 해소)
  (저장 기능은 유지)
- 가계부 quick-bar 에 ' 자주 쓰는 내역' 라벨 추가
- useQuick: 등록 성공과 목록 갱신 분리 — 갱신 단계 오류가 '등록 실패'로
  오인되던 문제 방지(앱에서 등록 안 됨처럼 보이던 케이스 대응)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:43:57 +09:00
ByungCheol c4c58d2e24 fix: 월 네비 가운데 유지·액션 우측 정렬 + 검색 모달 버튼 넘침 수정
- 가계부/예산 month-nav: 좌 spacer + 우 mn-actions(flex:1)로 네비 중앙 고정,
  추가/검색·필터 버튼 우측 정렬
- 커뮤니티 검색 모달: input min-width:0 + select/버튼 flex:none 으로
  검색 아이콘이 모달 밖으로 밀리는 문제 해결

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:35:24 +09:00
ByungCheol 59b17051a4 fix: 화면별 액션 아이콘 위치 정리(타이틀 제거 후 빈 헤더 해소)
CI / build (push) Failing after 12m2s
- 가계부: 내역 추가 → month-nav(검색·필터 옆), 헤더는 pending 있을 때만
- 예산: 예산 추가 → month-nav(월 변경 옆)
- 고정지출: 지금 반영/추가 → 안내문구 아래
- 커뮤니티: 글쓰기 → 태그 필터와 같은 줄(list-top)
- 분류 관리: 기본 분류 불러오기 → 지출/수입 탭과 같은 줄(tabs-row)
- 미사용 boardTitle/boardLabel 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:18:42 +09:00
ByungCheol c4872c93a8 feat: 페이지 타이틀 제거(헤더만 표기) + 게시글 상세 목록아이콘/공지 체크박스화
CI / build (push) Failing after 11m0s
- 각 화면 내 <h1> 페이지 타이틀 제거 → 헤더 타이틀로 일원화
  (가계부 pending 카운트는 유지, 액션 헤더는 우측 정렬)
- 게시글 상세: 공지 등록/해제 버튼 제거 + '목록' 아이콘 추가(list 아이콘 신규)
- 글 작성/수정 공지 체크박스로 일원화(편집 시 기존값 로드)
- ProfileEdit/AccountInfo 화면 내 뒤로가기 제거, 미사용 router 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:20 +09:00
ByungCheol b737720d29 fix: 전 화면 본문 폭 통일 + 화면 내 뒤로가기 버튼 제거
CI / build (push) Failing after 13m55s
- 본문 폭: --page-max(760px) 변수 + .layout-body>* 전역 규칙으로 일관화
  (각 뷰가 제각각 두던 root max-width 대체)
- 화면 내 '뒤로가기/가계부로/목록' 버튼 제거(하단 내비 뒤로가기는 유지)
  대상: 계좌/분류/예산/고정지출/태그/기본분류/게시글상세/계정정보
- BoardDetail actions 우측 정렬, AccountInfo 미사용 goBack 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:26:25 +09:00
ByungCheol 75228cbb64 feat: 사이드바 브랜드/헤더 타이틀 + 라이트·다크 테마 설정
1) 사이드바 상단에 돼지 로고 + '돈돼지 가계부'('메뉴' 텍스트 대체, 데스크톱/모바일 공통)
2) 헤더에서 돼지 로고 제거 → 현재 화면 타이틀 표시(라우트별)
3) 설정에 화면 테마(시스템/라이트/다크) 선택 추가
   - base.css: data-theme 기반 수동 다크 + 시스템 설정 폴백
   - theme.js 유틸 + ui 스토어 theme 상태, main.js 시작 시 적용(깜빡임 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:15:27 +09:00
ByungCheol 63b4792e58 fix: 웹 안내문 줄바꿈을 절 단위로 (inline-block 묶음)
- 'PC는 설치형 앱으로,' / '모바일은 앱으로 이용해요.' 등 절 경계에서만 줄바꿈

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:08:51 +09:00
ByungCheol f07cb349e2 feat: 게시판 공지 UI (관리자) — 작성 체크박스/상세 토글/목록 배지
- BoardWriteView: 관리자가 커뮤니티 새 글 작성 시 '공지로 등록' 체크박스
- BoardDetailView: 관리자 공지 등록/해제 버튼 + 제목 공지 배지
- BoardListView: 공지 배지 + 행 강조(최상단은 백엔드 정렬)
- boardApi: setNotice/unsetNotice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:07:03 +09:00
ByungCheol ef1d718fb8 feat: 통계 분류별 집계를 대분류 기준으로 (소분류 펼침 제거로 스크롤 단축)
- catByMajor: 소분류를 부모 대분류로 합산해 파이/범례 표기
- 중복되던 '대분류 합계' 섹션 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:38:07 +09:00
ByungCheol a92063dd64 chore: PC 설치를 사용자 폴더로 변경 (UAC 프롬프트 제거)
- installer.nsh: 기본 설치경로를 %LOCALAPPDATA%\Programs\DonDwaeji 로
  (perMachine=false 와 함께 관리자 권한 불필요 → '디바이스 변경 허용' UAC 안 뜸)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:09:34 +09:00
ByungCheol 6aa3590c5a chore: PC 설치파일명 DonDwaeji-Setup.exe + 기본 설치경로 DonDwaeji
CI / build (push) Failing after 12m51s
- electron-builder artifactName: SlimBudget-Setup → DonDwaeji-Setup
- build/installer.nsh: 기본 설치경로를 $PROGRAMFILES\DonDwaeji 로 고정
  (productName 한글이라 name='sb_pt' 로 폴백되던 문제 회피)
- WebOnlyNotice 다운로드 URL 도 DonDwaeji-Setup.exe 로 변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:47:46 +09:00
ByungCheol 4916d28604 fix: 웹 안내 페이지 한국어 줄바꿈(word-break: keep-all) — 'O요.' 고아줄 방지
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:34:24 +09:00
84 changed files with 6238 additions and 518 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
name: CI name: CI
on: on:
# main 은 Deploy 워크플로가 테스트+빌드를 수행하므로 CI 중복 실행 제외(배포 시간 단축).
push: push:
branches: [main, dev] branches: [dev]
pull_request: pull_request:
branches: [main, dev] branches: [main, dev]
+5
View File
@@ -99,3 +99,8 @@ app/src/main/assets/public
app/src/main/assets/capacitor.config.json app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml app/src/main/res/xml/config.xml
# 릴리스 서명 (절대 커밋 금지)
*.jks
*.keystore
key.properties
+22 -2
View File
@@ -1,5 +1,13 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
// 릴리스 서명: android/key.properties 가 있으면 사용, 없으면 디버그 서명으로 폴백.
def keystorePropsFile = rootProject.file("key.properties")
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) {
keystoreProps.load(new FileInputStream(keystorePropsFile))
}
def hasReleaseKeystore = keystorePropsFile.exists()
android { android {
namespace = "kr.sblog.slimbudget" namespace = "kr.sblog.slimbudget"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
@@ -7,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.
@@ -16,8 +24,20 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
signingConfigs {
if (hasReleaseKeystore) {
release {
storeFile rootProject.file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes { buildTypes {
release { release {
// 키스토어가 있으면 릴리스 서명, 없으면 디버그 서명(로컬 테스트용)
signingConfig hasReleaseKeystore ? signingConfigs.release : signingConfigs.debug
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
+3
View File
@@ -11,7 +11,10 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-camera') implementation project(':capacitor-camera')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-google-sign-in') implementation project(':capawesome-capacitor-google-sign-in')
} }
+5 -1
View File
@@ -29,7 +29,10 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider> </provider>
<!-- 카드 결제 알림 자동인식: 알림 접근 리스너 서비스 --> <!-- 카드 결제 알림 자동인식(알림 접근 리스너) — 스토어 첫 출시에서는 비활성화.
Google Play NotificationListenerService(알림 접근) 정책 심사 회피용으로 선언 제거.
추후 정당화/선언 후 재활성화하려면 아래 service 블록 주석을 해제할 것. -->
<!--
<service <service
android:name=".CardNotifListenerService" android:name=".CardNotifListenerService"
android:label="@string/app_name" android:label="@string/app_name"
@@ -39,6 +42,7 @@
<action android:name="android.service.notification.NotificationListenerService" /> <action android:name="android.service.notification.NotificationListenerService" />
</intent-filter> </intent-filter>
</service> </service>
-->
</application> </application>
<!-- Permissions --> <!-- Permissions -->
+9
View File
@@ -8,8 +8,17 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-camera' include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android') project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-google-sign-in' include ':capawesome-capacitor-google-sign-in'
project(':capawesome-capacitor-google-sign-in').projectDir = new File('../node_modules/@capawesome/capacitor-google-sign-in/android') project(':capawesome-capacitor-google-sign-in').projectDir = new File('../node_modules/@capawesome/capacitor-google-sign-in/android')
+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를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
+93
View File
@@ -0,0 +1,93 @@
// Play Console 스토어 그래픽 생성기.
// 입력: brand/logo-1024.png (사용자가 저장한 1024x1024 돈돼지 마스터 아트)
// 출력: brand/store/icon-512.png (스토어 고해상도 아이콘 512x512, 불투명)
// brand/store/feature-1024x500.png (피처 그래픽 — 텍스트 없음, 항상 안정)
// brand/store/feature-1024x500-text.png(피처 그래픽 — 한글 텍스트 포함, best-effort)
//
// 실행: node brand/gen-store-assets.mjs [입력경로]
import sharp from 'sharp'
import { mkdirSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const SRC = resolve(process.argv[2] || join(__dirname, 'logo-1024.png'))
const OUT = join(__dirname, 'store')
// 브랜드 색 (아이콘 배경의 크림/옐로 계열)
const CREAM = '#FBF7E9'
const YELLOW = '#F2C94C'
const BROWN = '#B8860B'
if (!existsSync(SRC)) {
console.error(`\n[!] 마스터 아트가 없습니다: ${SRC}`)
console.error(` 1024x1024 돈돼지 PNG 를 위 경로에 저장한 뒤 다시 실행하세요.\n`)
process.exit(1)
}
mkdirSync(OUT, { recursive: true })
const log = (f) => console.log(' ✓', f)
// 1) 스토어 아이콘 512x512 — 불투명 크림 배경 위에 마스터를 합성(투명 PNG 대비)
async function icon512() {
const logo = await sharp(SRC).resize(512, 512, { fit: 'contain', background: CREAM }).png().toBuffer()
const out = join(OUT, 'icon-512.png')
await sharp({ create: { width: 512, height: 512, channels: 4, background: CREAM } })
.composite([{ input: logo }])
.flatten({ background: CREAM })
.png()
.toFile(out)
log('store/icon-512.png (512x512, 불투명)')
}
// 공통: 1024x500 배경 — 피그가 놓일 우측(크림)에서 좌측(옐로 살짝)으로.
// 중심을 피그 쪽 크림으로 두어 마스터의 크림 배경 사각이 자연스럽게 녹도록 한다.
function bgSvg(cx = '80%') {
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
<defs>
<radialGradient id="g" cx="${cx}" cy="48%" r="95%">
<stop offset="0%" stop-color="${CREAM}"/>
<stop offset="62%" stop-color="${CREAM}"/>
<stop offset="100%" stop-color="${YELLOW}" stop-opacity="0.55"/>
</radialGradient>
</defs>
<rect width="1024" height="500" fill="url(#g)"/>
</svg>`)
}
// 2) 피처 그래픽 (텍스트 없음) — 가운데 큼직하게, 박스 없이 녹아들게
async function featurePlain() {
const bg = await sharp(bgSvg('50%')).png().toBuffer()
const pig = await sharp(SRC).resize(420, 420, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
const out = join(OUT, 'feature-1024x500.png')
await sharp(bg).composite([{ input: pig, top: 40, left: 302 }]).png().toFile(out)
log('store/feature-1024x500.png (텍스트 없음)')
}
// 3) 피처 그래픽 (한글 텍스트) — 좌측 텍스트 / 우측 피그, 겹침 없도록 간격 조정.
async function featureWithText() {
const bg = await sharp(bgSvg('82%')).png().toBuffer()
const pig = await sharp(SRC).resize(380, 380, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
const text = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
<style>
.t { font-family: 'Malgun Gothic','맑은 고딕',sans-serif; fill:${BROWN}; }
.title { font-size: 80px; font-weight: 800; }
.sub { font-size: 29px; font-weight: 500; fill:#8a6d10; }
</style>
<text x="64" y="232" class="t title">돈돼지 가계부</text>
<text x="68" y="290" class="t sub">슬림하게 관리하는</text>
<text x="68" y="332" class="t sub">가계부 · 자산 · 예산</text>
</svg>`)
const out = join(OUT, 'feature-1024x500-text.png')
await sharp(bg)
.composite([{ input: pig, top: 60, left: 630 }, { input: text }])
.png()
.toFile(out)
log('store/feature-1024x500-text.png (한글 텍스트, 폰트 렌더 확인 필요)')
}
console.log(`\n[스토어 에셋 생성] 입력: ${SRC}\n`)
await icon512()
await featurePlain()
await featureWithText()
console.log(`\n완료 → ${OUT}\n`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+9
View File
@@ -0,0 +1,9 @@
; 기본 설치 경로 지정 (사용자 폴더 = 관리자 권한 불필요 → UAC 프롬프트 없음).
; productName 이 한글이라 electron-builder 가 기본 경로를 package.json name(sb_pt)으로 폴백하는 문제도 함께 회피.
; → %LOCALAPPDATA%\Programs\DonDwaeji 로 고정 (사용자는 설치 시 변경 가능).
!macro preInit
SetRegView 64
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
SetRegView 32
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
!macroend
+51
View File
@@ -0,0 +1,51 @@
# 멤버십(무료/유료) 메뉴·기능 구분 — 설계 (추후 구현)
> 상태: **설계만** (미구현). 결제·게이팅은 추후 작업.
> 방향: **핵심 기록은 무료로 개방**(진입장벽 ↓), **과금 기능·자동화·고급 분석·무제한·백업은 유료**.
> 계좌 개수 제한은 **두지 않음**(1~소수 제한은 핵심 기능을 막아 거부감 → 제외).
---
## 1. 무료 / 유료 구분
| 메뉴 / 기능 | 무료 | 유료(프리미엄) | 비고 |
|---|:--:|:--:|---|
| **가계부 내역**(수입·지출 기록) | ✅ | ✅ | 핵심 — 무료 개방 |
| **계좌 관리**(은행·현금·카드·대출) | ✅ **무제한** | ✅ 무제한 | 개수 제한 없음 |
| 자주 쓰는 내역(빠른 등록) | ✅ | ✅ | 편의, 무료 |
| 월별 요약(수입/지출/잔액) | ✅ | ✅ | 기본 요약은 무료 |
| 분류 관리 | ✅ 기본+소수 | ✅ 무제한·소분류(2단계) | 무료는 기본 분류 위주 |
| 태그 관리 | — | ✅ | |
| **통계**(순자산 추이·예상 지출·지난달 대비·분류 분석) | — | ✅ | 고급 분석은 유료 |
| 예산 설정 | — | ✅ | |
| 고정 지출(자동 생성·확인) | — | ✅ | 자동화 = 유료 |
| **영수증 OCR** | — | ✅ | **Google Vision 과금** → 유료 필수 |
| **카드 결제 알림 자동인식** | — | ✅ | 강력한 유료 훅(편의·자동화) |
| 투자/자산(평가액) 관리 | — | ✅ | |
| 데이터 백업/복구(엑셀·JSON) | — | ✅ | 추후 백업 기능과 연계 |
| 게시판(커뮤니티/짠테크/재테크 팁) | ✅ | ✅ | 커뮤니티는 전체 개방 |
| 다크모드·테마 | ✅ | ✅ | 기본 제공 |
| 광고 | (도입 시) 표시 | 제거 | 광고 모델은 미정 |
## 2. 구분 기준
- **무료** = 기본 기록 + (무제한)계좌 + 기본 분류 + 월 요약 → "충분히 써볼 만한" 수준
- **유료** = ① 과금 기능(OCR) · ② 자동화(카드 알림 인식 · 고정지출) · ③ 고급 분석(통계 전체 · 예산) · ④ 무제한(태그/소분류) · ⑤ 백업/복구
- 원칙: **무료를 일부러 불편하게 만들지 않고, 유료에 "확실히 편한 것"을 몰아준다**(전환율 우선).
## 3. 구현 시 필요한 것 (추후)
- **데이터**: `member.plan`(`FREE`/`PREMIUM`) + `plan_expires_at`(만료일) 컬럼.
- **게이팅(이중)**:
- 프론트: 유료 메뉴에 **잠금 배지 + 업그레이드 유도**(데모의 잠금 화면 재사용 가능), 비유료는 진입 차단.
- 백엔드: 해당 API에 **plan 권한 체크**(프론트 우회 방지). OCR·알림 인식·통계·예산·고정지출·투자·백업 엔드포인트.
- **결제 연동**: 안드로이드 인앱결제(Google Play Billing) / 웹·PC는 PG. 정기결제 시 만료일 갱신.
- **기존 데이터 보호**: 유료→무료 강등(만료) 시 데이터는 보존하되 **유료 메뉴만 잠금**(읽기 정도는 허용 검토).
## 4. 추후 결정 필요(미정)
- 가격·주기(월/연), 무료체험 기간.
- 광고 모델 도입 여부(무료=광고 / 유료=제거).
- 무료 "기본 분류" 범위(개수·기본 템플릿만 허용 여부).
- 강등 시 잠금 정책(완전 차단 vs 읽기 허용).
---
*참고: 데모(`/demo`)의 잠금 안내 화면을 유료 게이팅 UI 의 기반으로 재사용할 수 있음.*
+55 -1
View File
@@ -156,13 +156,67 @@
--- ---
# (이어진 작업 4) 구글 로그인 · 다크모드 · UI 개편 (2026-06-28)
## 34. 구글 로그인/가입 (소셜 로그인)
- **웹/PC/앱 3플랫폼 구글 로그인**: 웹·PC(Electron)는 **Google Identity Services(GIS)** ID 토큰, 앱(안드로이드)은 **네이티브 플러그인 `@capawesome/capacitor-google-sign-in`**(Credential Manager). 모두 ID 토큰을 백엔드 `POST /api/auth/google` 로 전달 → **tokeninfo 로 검증(aud=웹 클라이언트 ID)** 후 세션 발급.
- **PC(Electron) 팝업 수정**: `accounts.google.com` 팝업을 외부 브라우저로 보내 결과가 안 돌아오던 문제 → **앱 내부 자식 창**으로 열어 opener 로 credential 전달.
- **같은 이메일 자동 연결**: 구글 로그인 시 `member.google_id`(신규 컬럼)로 조회, 없으면 **검증된 동일 이메일의 기존 계정에 연결**(provider 유지 → 아이디 로그인/프로필 편집 보존), 그래도 없으면 신규 생성.
- 설정 필요: Google Cloud **웹 + Android OAuth 클라이언트**(패키지 `kr.sblog.slimbudget` + 서명 SHA-1), 서버 `.env` `GOOGLE_CLIENT_ID`.
## 35. 구글 전용 로그인 (아이디 로그인/회원가입 숨김)
- `src/config/features.js` `ID_LOGIN_ENABLED=false` 플래그로 **아이디/비번 폼·회원가입 UI 숨김**(로그인 모달·홈 랜딩). 백엔드 `/auth/login`·`/signup` 은 비상용으로 유지(복구 가능).
## 36. 브랜딩 — 돈돼지 가계부
- 앱/파비콘/PC 아이콘을 **돈돼지(투명 배경)** 로 교체, 헤더 로고·런처 라벨 정리. 런처 라벨 `돈돼지가계부`(한 줄), 앱 표시명 `돈돼지 가계부`.
## 37. PC 설치파일 (Electron)
- 산출물명 `SlimBudget-Setup.exe`**`DonDwaeji-Setup.exe`**, 다운로드 URL `/download/DonDwaeji-Setup.exe`.
- **사용자 폴더 설치**(`%LOCALAPPDATA%\Programs\DonDwaeji`, `build/installer.nsh`)로 변경 → **관리자 권한(UAC) 프롬프트 제거**. (게시자 미서명 경고/SmartScreen 은 인증서 없이는 잔존)
## 38. 게시판 공지 기능 (관리자)
- `post.notice` 컬럼(멱등 ALTER), 목록 **정렬 `notice DESC, id DESC`(공지 최상단)**, 목록·상세 **공지 배지**.
- 공지 설정은 **글 작성/수정 시 체크박스**로 일원화(커뮤니티+관리자). 상세의 공지 토글 버튼 제거, **목록 이동 아이콘** 추가.
## 39. 라이트/다크 테마
- 설정 화면에 **시스템 / 라이트 / 다크** 선택. `base.css`**`data-theme` 기반 수동 다크 + 시스템 폴백**으로 전환, `theme.js` 유틸 + ui 스토어 상태, `main.js` 시작 시 즉시 적용(깜빡임 방지).
## 40. 사이드바/헤더 개편 · 레이아웃 정리
- 사이드바 상단에 **돼지 로고 + '돈돼지 가계부'**(메뉴 텍스트 대체), 사이드바 **메뉴 아이콘**(lucide) 추가. **헤더는 현재 화면 타이틀** 표시(돼지 로고 제거).
- **PC 레이아웃**: 그리드 `'left top'` → 사이드바가 **좌측 상단(헤더 행)까지** 차지, 헤더는 우측 콘텐츠 영역만.
- **화면 내 페이지 타이틀(`<h1>`) 제거 → 헤더로 일원화**, **화면 내 뒤로가기 버튼 제거**(하단 내비 뒤로가기만 유지).
- **전 화면 본문 폭 통일**: `--page-max`(760px) + `.layout-body > *` 전역 규칙(뷰별 제각각 max-width 대체).
## 41. 화면별 액션 아이콘 위치 정리
- 가계부/예산 **month-nav**: 좌 spacer + 우 액션으로 **월 변경은 가운데, 추가·검색/필터는 우측**. 고정지출: 안내문구 아래. 커뮤니티 글쓰기: 태그 필터와 같은 줄. 분류: 지출/수입 탭과 같은 줄.
- **커뮤니티 검색 모달**: input `min-width:0` 으로 **검색 버튼이 모달 밖으로 밀리던 문제** 수정.
## 42. 자주 쓰는 내역 UX 정리
- 등록 모달의 **'자주 쓰는 내역 불러오기' 버튼·피커 모달 제거**(번거로운 3단계 해소, 저장 기능은 유지). 가계부 quick-bar 에 **'⭐ 자주 쓰는 내역' 라벨** 추가.
- 빠른등록(`useQuick`) **등록 성공과 목록 갱신 분리** — 갱신 오류가 '등록 실패'로 오인되던 케이스 방지.
## 43. 계정정보 단순화 · 구글 계정 이름 변경
- 계정정보에서 **아이디·가입유형 행 제거**(이름/이메일/권한만). **소셜(구글) 계정도 비밀번호 재인증 없이 이름만 변경** 가능(ProfileEdit), 백엔드 `updateProfile` 그대로.
## 44. PC 화면 해상도 변경
- Electron **preload(`window.desktop`) + IPC(`window:setSize`)** 추가, 설정 화면(PC)에 **창 크기 프리셋**(1024×720 ~ 1920×1080). 선택 크기는 Electron 창 상태 저장으로 다음 실행에도 유지.
## 45. 기타
- 통계 '분류별 지출/수입'을 **대분류 기준 집계**(소분류 펼침 제거로 스크롤 단축).
- 게시판 작성일: 1시간 이내 'N분 전' / 1일 이내 'N시간 전' / 그 이상 **`YYYY.MM.DD`**.
- 웹 안내 페이지 한국어 줄바꿈 **절(어절) 단위**(`word-break: keep-all` + inline-block 묶음).
---
## 운영 반영 시 체크 ## 운영 반영 시 체크
- 서버 `/opt/sb-backend/.env` - 서버 `/opt/sb-backend/.env`
- `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음). - `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음).
- `GOOGLE_CLIENT_ID` — 구글 로그인 웹 클라이언트 ID(미설정 시 구글 버튼 미노출). **앱 구글 로그인**은 Google Cloud 에 **Android OAuth 클라이언트**(패키지 `kr.sblog.slimbudget` + 서명 SHA-1) 등록 필요.
- `member.google_id`(계정 연결) · `post.notice`(공지) 컬럼 — 기동 시 **멱등 ALTER** 자동 적용.
- `app_setting` · `auth_session` · `board_image` 테이블 — 배포 시 `member.sql`/`board.sql` 초기화로 자동 생성(가입 허용 / 세션 DB 백업 / 게시글 이미지). - `app_setting` · `auth_session` · `board_image` 테이블 — 배포 시 `member.sql`/`board.sql` 초기화로 자동 생성(가입 허용 / 세션 DB 백업 / 게시글 이미지).
- Redis `appendonly yes` — 세션 영속(이미 적용). 단, 세션 DB 이중 저장으로 Redis 유실에도 로그인 유지. - Redis `appendonly yes` — 세션 영속(이미 적용). 단, 세션 DB 이중 저장으로 Redis 유실에도 로그인 유지.
- 서버 `.env`/환경변수 `PUBLIC_URL`(기본 `https://app.sblog.kr`) — 업로드 이미지 절대 URL 베이스(앱에서 이미지 로드에 필요). - 서버 `.env`/환경변수 `PUBLIC_URL`(기본 `https://app.sblog.kr`) — 업로드 이미지 절대 URL 베이스(앱에서 이미지 로드에 필요).
- **nginx**(`deploy/nginx-sb-front.conf` 참고): `location /``Cache-Control no-cache`(진입 HTML 최신화), `location /download/``/var/www/sb-downloads`(설치파일, 배포에 안 지워지는 영속 경로). - **nginx**(`deploy/nginx-sb-front.conf` 참고): `location /``Cache-Control no-cache`(진입 HTML 최신화), `location /download/``/var/www/sb-downloads`(설치파일, 배포에 안 지워지는 영속 경로).
- 앱 버전 표기는 `package.json``version`(현재 `1.0.0`)을 단일 출처로 사용. - 앱 버전 표기는 `package.json``version`(현재 `1.0.0`)을 단일 출처로 사용.
- **PC 설치파일**: `npm run electron:build``release-desktop/SlimBudget-Setup.exe`(서명 없음, SmartScreen 경고 정상). - **PC 설치파일**: `npm run electron:build``release-desktop/DonDwaeji-Setup.exe`(**사용자 폴더 설치=UAC 없음**, 미서명 → SmartScreen 경고 정상). 서버는 `/var/www/sb-downloads/DonDwaeji-Setup.exe` 로 업로드.
- 배포는 `main` 푸시 → CI·Deploy 워크플로 모두 **테스트 통과해야 진행**(Gitea Actions). - 배포는 `main` 푸시 → CI·Deploy 워크플로 모두 **테스트 통과해야 진행**(Gitea Actions).
+99 -12
View File
@@ -1,15 +1,43 @@
// Slim Budget 데스크톱(Electron) 메인 프로세스. // 돈돼지 가계부 데스크톱(Electron) 메인 프로세스.
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요), // 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다. // 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
const { app, BrowserWindow, shell, session } = require('electron') //
// 로딩 UX: 예전에는 시작 시 HTTP 캐시를 통째로 비워 매번 전체 자산을 재다운로드 → 흰 화면이 오래 노출됐다.
// 이제 index.html 만 캐시버스트(?_=t)로 항상 최신을 받고, 해시된 정적 자산(JS/CSS)은 캐시를 유지해
// 재시작이 빠르다. 로드되는 동안에는 스플래시(로딩) 창을 띄워 빈 화면을 가린다.
// 또한 단일 인스턴스 잠금으로 중복 실행을 막아(두 번째 실행 시 사이트를 다시 로드하며 빈 화면이 보이던 문제),
// 이미 실행 중이면 기존 창을 활성화한다.
const { app, BrowserWindow, shell, ipcMain, Menu } = require('electron')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
// 기본 애플리케이션 메뉴(File/Edit/View/Window) 제거 — 메인 창뿐 아니라
// 구글 로그인 팝업 등 자식 창에도 메뉴바가 안 붙도록 전역으로 비활성화.
Menu.setApplicationMenu(null)
// 네이티브 대화상자(alert/confirm) 제목은 app.getName() 을 쓴다 → package.json name('sb_pt') 대신 표기. // 네이티브 대화상자(alert/confirm) 제목은 app.getName() 을 쓴다 → package.json name('sb_pt') 대신 표기.
app.setName('돈돼지 가계부') app.setName('돈돼지 가계부')
const APP_URL = 'https://app.sblog.kr' const APP_URL = 'https://app.sblog.kr'
// 현재 메인 창 참조(중복 실행 시 활성화 대상).
let mainWindow = null
// 중복 실행 방지: 잠금을 못 얻으면 이미 실행 중 → 새 인스턴스를 즉시 종료한다.
// (두 번째 실행에서 라이브 사이트를 다시 로드하느라 빈 화면이 보이던 현상 해결)
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
// 두 번째 실행 시도가 들어오면 기존 창을 복원/활성화.
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore()
if (!mainWindow.isVisible()) mainWindow.show()
mainWindow.focus()
}
})
}
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지) // 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
function stateFile() { function stateFile() {
return path.join(app.getPath('userData'), 'window-state.json') return path.join(app.getPath('userData'), 'window-state.json')
@@ -31,6 +59,23 @@ function saveState(win) {
} }
} }
// 스플래시(로딩) 화면 — 라이브 사이트가 첫 렌더될 때까지 빈 흰 화면 대신 표시.
function splashHtml() {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
<style>html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center;
background:#1a1a1a;color:#eee;font-family:system-ui,'Segoe UI',sans-serif;-webkit-user-select:none;user-select:none}
.box{text-align:center}
.pig{font-size:46px;line-height:1}
.name{margin-top:10px;font-size:17px;font-weight:700;letter-spacing:-0.01em}
.spin{margin:18px auto 0;width:26px;height:26px;border:3px solid rgba(255,255,255,.2);
border-top-color:#00bc7e;border-radius:50%;animation:r .8s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
.tip{margin-top:12px;font-size:12px;opacity:.5}</style></head>
<body><div class="box"><div class="pig">🐷</div><div class="name">돈돼지 가계부</div>
<div class="spin"></div><div class="tip">불러오는 중…</div></div></body></html>`
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
}
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼) // 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
function offlineHtml() { function offlineHtml() {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title> const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
@@ -53,27 +98,61 @@ function createWindow() {
y: s?.y, y: s?.y,
minWidth: 360, minWidth: 360,
minHeight: 560, minHeight: 560,
show: false, // 첫 렌더(ready-to-show) 전까지 숨김 → 빈 흰 화면 노출 방지(스플래시가 대신 보임)
backgroundColor: '#1a1a1a', backgroundColor: '#1a1a1a',
title: '돈돼지 가계부', title: '돈돼지 가계부',
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
preload: path.join(__dirname, 'preload.cjs'),
// 한글 IME 첫 글자 중복 입력 방지 — Chromium 맞춤법 검사기가 조합을 방해하는 Electron 알려진 버그 회피 // 한글 IME 첫 글자 중복 입력 방지 — Chromium 맞춤법 검사기가 조합을 방해하는 Electron 알려진 버그 회피
spellcheck: false, spellcheck: false,
}, },
}) })
if (s?.maximized) win.maximize() if (s?.maximized) win.maximize()
mainWindow = win
// 로딩 스플래시(프레임 없는 작은 창) — 메인이 준비되면 닫는다.
const splash = new BrowserWindow({
width: 300,
height: 300,
frame: false,
resizable: false,
movable: false,
skipTaskbar: true,
alwaysOnTop: true,
backgroundColor: '#1a1a1a',
title: '돈돼지 가계부',
})
splash.loadURL(splashHtml())
splash.center()
// 메인 창을 한 번만 노출하고 스플래시를 정리(이벤트 중복/지연 대비).
let revealed = false
const reveal = () => {
if (revealed) return
revealed = true
if (!win.isDestroyed()) win.show()
if (!splash.isDestroyed()) splash.destroy()
}
win.once('ready-to-show', reveal)
// 안전장치: 로드가 비정상적으로 지연돼도 12초 후엔 메인 창을 띄운다.
setTimeout(reveal, 12000)
// 기본 메뉴바 제거(앱 자체 내비게이션 사용) // 기본 메뉴바 제거(앱 자체 내비게이션 사용)
win.removeMenu() win.removeMenu()
win.loadURL(APP_URL) // index.html 만 캐시버스트(?_=t) → 항상 최신 프론트. 해시 자산은 캐시 유지 → 빠른 재시작.
win.loadURL(APP_URL + '/?_=' + Date.now())
// 닫을 때 창 크기/위치 저장 → 다음 실행에 복원 // 닫을 때 창 크기/위치 저장 → 다음 실행에 복원
win.on('close', () => saveState(win)) win.on('close', () => saveState(win))
// 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시. // 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시.
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame && errorCode !== -3) win.loadURL(offlineHtml()) if (isMainFrame && errorCode !== -3) {
win.loadURL(offlineHtml())
reveal() // 오프라인 안내도 보이도록 메인 창 노출
}
}) })
// 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리 // 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리
@@ -91,14 +170,22 @@ function createWindow() {
}) })
} }
app.whenReady().then(async () => { // 데스크톱 창 크기(해상도) 변경 — 렌더러(설정 화면)에서 호출
// 시작 시 HTTP 캐시 비움 → 라이브 사이트의 최신 프론트를 항상 로드(옛 HTML 캐시로 인한 미반영 방지). ipcMain.handle('window:setSize', (e, w, h) => {
// localStorage(로그인 세션)는 캐시가 아니라 영향 없음. const win = BrowserWindow.fromWebContents(e.sender)
try { if (!win) return false
await session.defaultSession.clearCache() if (win.isMaximized()) win.unmaximize()
} catch { win.setSize(Math.max(360, Math.round(w)), Math.max(560, Math.round(h)))
/* 캐시 클리어 실패 무시 */ win.center()
} return true
})
ipcMain.handle('window:getSize', (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
return win ? win.getSize() : [0, 0]
})
app.whenReady().then(() => {
// 캐시는 비우지 않는다(빠른 재시작). 최신 프론트는 index.html 캐시버스트로 보장.
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow() if (BrowserWindow.getAllWindows().length === 0) createWindow()
+8
View File
@@ -0,0 +1,8 @@
// 렌더러(웹)에서 안전하게 호출할 데스크톱 전용 API 노출 (contextIsolation 유지).
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('desktop', {
// 데스크톱 창 크기(해상도) 변경
setWindowSize: (w, h) => ipcRenderer.invoke('window:setSize', w, h),
getWindowSize: () => ipcRenderer.invoke('window:getSize'),
})
+144 -1
View File
@@ -13,7 +13,10 @@
"@capacitor/camera": "^8.2.0", "@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
@@ -22,7 +25,8 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
@@ -1187,6 +1191,27 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz",
"integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/preferences": { "node_modules/@capacitor/preferences": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
@@ -1196,6 +1221,21 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capacitor/share": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capawesome/capacitor-google-sign-in": { "node_modules/@capawesome/capacitor-google-sign-in": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz",
@@ -4015,6 +4055,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -5182,6 +5231,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -5378,6 +5440,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -5782,6 +5853,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -7479,6 +7562,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -12540,6 +12632,18 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@@ -14175,6 +14279,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -14297,6 +14419,27 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-js": { "node_modules/xml-js": {
"version": "1.6.11", "version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
+8 -3
View File
@@ -31,12 +31,13 @@
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "build/icon.ico", "icon": "build/icon.ico",
"artifactName": "SlimBudget-Setup.${ext}" "artifactName": "DonDwaeji-Setup.${ext}"
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true,
"include": "build/installer.nsh"
} }
}, },
"dependencies": { "dependencies": {
@@ -45,7 +46,10 @@
"@capacitor/camera": "^8.2.0", "@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
@@ -54,7 +58,8 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
+331 -9
View File
@@ -1,20 +1,40 @@
<script setup> <script setup>
import { onMounted, onUnmounted, watch } from 'vue' import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute } 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'
import AppBottomNav from '@/components/layout/AppBottomNav.vue' import AppBottomNav from '@/components/layout/AppBottomNav.vue'
import AdBanner from '@/components/AdBanner.vue'
import LoginModal from '@/components/LoginModal.vue' 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 { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo, exitDemo } from '@/demo'
import { ADS_ENABLED } from '@/config/features'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
const authed = computed(() => auth.isAuthenticated || demo.on)
// 광고: 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시 (레이아웃 하단 여백도 함께 제어)
const showAds = computed(() => ADS_ENABLED && !auth.isPremium)
// 공개 법적 고지(개인정보처리방침·약관)는 웹 브라우저에서도 접근 허용 (앱 마켓 정책 URL)
const isPublicLegal = computed(() => !!route.meta.public)
function leaveDemo() {
exitDemo()
router.push('/')
}
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용. // 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시. // 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
@@ -26,49 +46,186 @@ function onUnauthorized() {
auth.clear() auth.clear()
ui.openLogin() ui.openLogin()
} }
// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로
function onPremiumRequired() {
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('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) {
auth.refreshProfile().then(() => {
if (reminders.isNative()) {
reminders.scheduleExpiryReminder({
premium: auth.isPremium,
autoRenew: !!auth.user?.planAutoRenew,
expiresAt: auth.user?.planExpiresAt,
})
}
})
if (reminders.isNative()) reminders.scheduleEntryReminder(false) // 오늘 기록 여부는 홈/내역에서 보정
}
})
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized)
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 // 상위로 전파 중단
}) })
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
// 페이지 이동 시 모바일 사이드바 자동 닫힘 // 페이지 이동 시 모바일 사이드바 자동 닫힘
watch(() => route.fullPath, () => ui.closeSidebar()) watch(() => route.fullPath, () => ui.closeSidebar())
</script> </script>
<template> <template>
<!-- (브라우저): 안내 페이지만 --> <!-- 치명적 오류 폴백 ( 화면 방지) -->
<WebOnlyNotice v-if="!isApp" /> <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">
<RouterView v-if="isPublicLegal" />
<WebOnlyNotice v-else />
</template>
<!-- (Capacitor): 전체 기능 --> <!-- (Capacitor): 전체 기능 -->
<template v-else> <template v-else>
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !auth.isAuthenticated }"> <!-- 잠금 화면 (로그인 상태 + 잠금 활성 ) -->
<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 }">
<AppHeader class="layout-top" /> <AppHeader class="layout-top" />
<!-- 로그인 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) --> <!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) -->
<template v-if="auth.isAuthenticated"> <template v-if="authed">
<AppSidebar class="layout-left" /> <AppSidebar class="layout-left" />
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div> <div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
</template> </template>
<main class="layout-body"> <main class="layout-body">
<div v-if="demo.on" class="demo-bar">
<span>👀 <b>둘러보기 모드</b> · 샘플 데이터입니다.</span>
<span class="demo-actions">
<button type="button" class="db-login" @click="ui.openLogin('/account')">로그인</button>
<button type="button" class="db-exit" @click="leaveDemo">나가기</button>
</span>
</div>
<RouterView /> <RouterView />
</main> </main>
<!-- 하단 내비 광고 (유료 회원 제외). 앱은 320x50 모바일 배너(임시) -->
<AdBanner v-if="showAds" class="app-ad" unit="DAN-qd8As3nqJjXTQKa6" :width="320" :height="50" />
<AppBottomNav class="layout-bottom" /> <AppBottomNav class="layout-bottom" />
</div> </div>
<LoginModal /> <LoginModal />
<SignupModal /> <SignupModal />
<!-- 토스트 (뒤로가기 종료 안내 ) -->
<Transition name="toast">
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
</Transition>
</template> </template>
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) --> <!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
<AppDialog /> <AppDialog />
</template>
</template> </template>
<style scoped> <style scoped>
.layout { .layout {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'top top' 'left top'
'left body' 'left body'
'bottom bottom'; 'bottom bottom';
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
@@ -84,6 +241,156 @@ watch(() => route.fullPath, () => ui.closeSidebar())
grid-area: left; grid-area: left;
} }
.demo-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
padding: 0.55rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 8px;
background: hsla(160, 100%, 37%, 0.08);
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 {
display: flex;
gap: 0.4rem;
}
.demo-bar button {
padding: 0.35rem 0.8rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.db-login {
border: 1px solid hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.db-exit {
border: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
}
.layout-body { .layout-body {
grid-area: body; grid-area: body;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
@@ -96,6 +403,21 @@ watch(() => route.fullPath, () => ui.closeSidebar())
grid-area: bottom; grid-area: bottom;
} }
/* 하단 내비(56px) 바로 위 고정 광고 */
.app-ad {
position: fixed;
left: 0;
right: 0;
bottom: calc(56px + env(safe-area-inset-bottom, 0));
z-index: 999;
background: var(--color-background);
border-top: 1px solid var(--color-border);
}
/* 광고가 있을 땐 본문 하단 여백을 광고 높이(320x50 배너)만큼 더 확보 */
.layout.has-ad .layout-body {
padding-bottom: calc(56px + 62px + env(safe-area-inset-bottom, 0) + 1rem);
}
/* 로그인 전: 사이드바 없이 단일 컬럼 — 콘텐츠가 창 중앙에 오도록 */ /* 로그인 전: 사이드바 없이 단일 컬럼 — 콘텐츠가 창 중앙에 오도록 */
.layout.no-sidebar { .layout.no-sidebar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+54 -5
View File
@@ -1,7 +1,8 @@
import http from './http' import http from './http'
import { demo, demoApi } from '@/demo'
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만) // 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
export const accountApi = { const realApi = {
list({ year, month, type, category, walletId, keyword, tagId } = {}) { list({ year, month, type, category, walletId, keyword, tagId } = {}) {
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } }) return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
}, },
@@ -30,6 +31,10 @@ export const accountApi = {
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() {
@@ -71,8 +76,12 @@ export const accountApi = {
}, },
// 예산 (사용자별) // 예산 (사용자별)
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 } })
@@ -86,8 +95,8 @@ export const accountApi = {
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)
@@ -188,6 +197,11 @@ export const accountApi = {
return http.post(`/account/entries/${id}/confirm`, payload || {}) return http.post(`/account/entries/${id}/confirm`, payload || {})
}, },
// 데이터 복구(가져오기) — 전체 데이터 덮어쓰기
restore(payload) {
return http.post('/account/restore', payload)
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) // 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
ocrReceipt(blob) { ocrReceipt(blob) {
const fd = new FormData() const fd = new FormData()
@@ -198,3 +212,38 @@ export const accountApi = {
}) })
}, },
} }
// ===== 둘러보기(데모) 모드 =====
// demo.on 이면 읽기는 더미 데이터, 쓰기는 차단(안내 메시지)으로 응답한다.
const DEMO_READS = {
list: demoApi.list,
summary: demoApi.summary,
wallets: demoApi.wallets,
netWorth: demoApi.netWorth,
categories: demoApi.categories,
recurrings: demoApi.recurrings,
quickEntries: demoApi.quickEntries,
pendingCount: demoApi.pendingCount,
tags: demoApi.tags,
walletEntries: demoApi.walletEntries,
}
const DEMO_WRITES = new Set([
'create', 'update', 'remove', 'parseText', 'createQuickEntry', 'removeQuickEntry', 'repayment',
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices',
])
export const accountApi = new Proxy(realApi, {
get(target, prop) {
if (demo.on) {
if (DEMO_READS[prop]) return DEMO_READS[prop]
if (DEMO_WRITES.has(prop)) return () => demoApi.block()
}
return target[prop]
},
})
+3 -6
View File
@@ -23,12 +23,6 @@ export const adminApi = {
removeTag(id) { removeTag(id) {
return http.delete(`/admin/tags/${id}`) return http.delete(`/admin/tags/${id}`)
}, },
getBoardSetting() {
return http.get('/admin/board-setting')
},
setBoardSetting(tagCategoryId) {
return http.put('/admin/board-setting', { tagCategoryId })
},
// 기본(디폴트) 분류 관리 (관리자) — 사용자가 '기본 분류 불러오기'로 가져감 // 기본(디폴트) 분류 관리 (관리자) — 사용자가 '기본 분류 불러오기'로 가져감
defaultCategories() { defaultCategories() {
@@ -62,6 +56,9 @@ export const adminApi = {
updateMemberRole(id, role) { updateMemberRole(id, role) {
return http.put(`/admin/members/${id}/role`, { role }) return http.put(`/admin/members/${id}/role`, { role })
}, },
updateMemberPlan(id, plan) {
return http.put(`/admin/members/${id}/plan`, { plan })
},
updateMemberStatus(id, status) { updateMemberStatus(id, status) {
return http.put(`/admin/members/${id}/status`, { status }) return http.put(`/admin/members/${id}/status`, { status })
}, },
+20
View File
@@ -35,4 +35,24 @@ export const authApi = {
updateProfile(payload) { updateProfile(payload) {
return http.put('/auth/profile', payload) return http.put('/auth/profile', payload)
}, },
// 프로필 사진 변경/해제 — image 가 null/빈값이면 해제(구글 사진으로 폴백)
updateProfileImage(image) {
return http.put('/auth/profile-image', { image })
},
// 현재 사용자 활동 포인트 (최신값)
points() {
return http.get('/auth/points')
},
// 포인트 적립/차감 내역 (최신순)
pointHistory() {
return http.get('/auth/point-history')
},
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
profile() {
return http.get('/auth/profile')
},
// 회원 탈퇴 — 계정·데이터 전체 삭제
withdraw() {
return http.delete('/auth/me')
},
} }
+29
View File
@@ -0,0 +1,29 @@
import http from './http'
// 백엔드 /api/billing 엔드포인트와 매핑
export const billingApi = {
// 구독 상품 목록
products() {
return http.get('/billing/products')
},
// Google Play 구매 검증 → 멤버십 부여
verifyGoogle(payload) {
return http.post('/billing/google/verify', payload)
},
// 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신)
subscription() {
return http.get('/billing/subscription')
},
// 구독 해지 (자동 갱신 중단)
cancel() {
return http.post('/billing/cancel')
},
// 구독 재개 (만료 전 자동 갱신 재개)
resume() {
return http.post('/billing/resume')
},
// 구매 복원 (기기 변경·재설치)
restore() {
return http.post('/billing/restore')
},
}
+40 -4
View File
@@ -17,6 +17,13 @@ export const boardApi = {
get(id) { get(id) {
return http.get(`/board/posts/${id}`) return http.get(`/board/posts/${id}`)
}, },
// 내 글/댓글 모아보기
myPosts({ page = 1, size = 10 } = {}) {
return http.get('/board/my/posts', { params: { page, size } })
},
myComments({ page = 1, size = 10 } = {}) {
return http.get('/board/my/comments', { params: { page, size } })
},
create(payload) { create(payload) {
return http.post('/board/posts', payload) return http.post('/board/posts', payload)
}, },
@@ -35,11 +42,11 @@ export const boardApi = {
timeout: 30000, timeout: 30000,
}) })
}, },
tags() { tags(category) {
return http.get('/board/tags') return http.get('/board/tags', { params: { category: category || undefined } })
}, },
tagGroups() { tagGroups(category) {
return http.get('/board/tag-groups') return http.get('/board/tag-groups', { params: { category: category || undefined } })
}, },
addComment(id, content) { addComment(id, content) {
return http.post(`/board/posts/${id}/comments`, { content }) return http.post(`/board/posts/${id}/comments`, { content })
@@ -47,10 +54,39 @@ export const boardApi = {
removeComment(commentId) { removeComment(commentId) {
return http.delete(`/board/comments/${commentId}`) return http.delete(`/board/comments/${commentId}`)
}, },
// 추천/비추천 (토글) — type: 'UP' | 'DOWN'
votePost(id, type) {
return http.post(`/board/posts/${id}/vote`, { type })
},
voteComment(commentId, type) {
return http.post(`/board/comments/${commentId}/vote`, { type })
},
// 추천(👍)한 사람 목록
recommenders(id) {
return http.get(`/board/posts/${id}/recommenders`)
},
// 신고 (누적 5건 시 블라인드)
reportPost(id) {
return http.post(`/board/posts/${id}/report`)
},
reportComment(commentId) {
return http.post(`/board/comments/${commentId}/report`)
},
// 댓글 블라인드 해제 (관리자)
unblockComment(commentId) {
return http.post(`/board/comments/${commentId}/unblock`)
},
block(id, reason) { block(id, reason) {
return http.post(`/board/posts/${id}/block`, { reason }) return http.post(`/board/posts/${id}/block`, { reason })
}, },
unblock(id) { unblock(id) {
return http.post(`/board/posts/${id}/unblock`) return http.post(`/board/posts/${id}/unblock`)
}, },
// 공지 설정/해제 (관리자) — 목록 최상단 고정
setNotice(id) {
return http.post(`/board/posts/${id}/notice`)
},
unsetNotice(id) {
return http.post(`/board/posts/${id}/unnotice`)
},
} }
+6
View File
@@ -31,6 +31,12 @@ http.interceptors.response.use(
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('auth_user') localStorage.removeItem('auth_user')
window.dispatchEvent(new CustomEvent('auth:unauthorized')) window.dispatchEvent(new CustomEvent('auth:unauthorized'))
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required'))
} else if (!error.response && error.code !== 'ERR_CANCELED') {
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
window.dispatchEvent(new CustomEvent('net:error'))
} }
return Promise.reject(error) return Promise.reject(error)
}, },
+18 -2
View File
@@ -34,10 +34,26 @@
--color-text: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1);
--section-gap: 160px; --section-gap: 160px;
/* 모든 화면 본문 공통 폭 (화면별 제각각이던 max-width 를 일관되게) */
--page-max: 760px;
} }
/* 다크 변수 묶음 — 명시적 다크(data-theme=dark)와 시스템 다크에서 공통 사용 */
:root[data-theme='dark'] {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-1);
}
/* 시스템 설정 따름(테마 미지정 = 시스템). 명시적 라이트/다크가 있으면 그게 우선 */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme]) {
--color-background: var(--vt-c-black); --color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft); --color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute); --color-background-mute: var(--vt-c-black-mute);
@@ -46,7 +62,7 @@
--color-border-hover: var(--vt-c-divider-dark-1); --color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1); --color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2); --color-text: var(--vt-c-text-dark-1);
} }
} }
+8
View File
@@ -4,6 +4,14 @@
font-weight: normal; font-weight: normal;
} }
/* 모든 화면 본문 폭 통일 — 각 뷰가 제각각 두던 root max-width 를 한 곳에서 일관되게.
(본문 직계 자식 = 각 화면 루트 컨테이너) scoped 스타일을 덮기 위해 !important 사용. */
.layout-body > * {
max-width: var(--page-max) !important;
margin-left: auto !important;
margin-right: auto !important;
}
/* 폼 요소 폰트 상속(표준 리셋) — 네이티브 input/select/textarea 가 서로 폰트·높이 달라 /* 폼 요소 폰트 상속(표준 리셋) — 네이티브 input/select/textarea 가 서로 폰트·높이 달라
보이던 이질감 해소. (개별 화면 scoped 스타일이 더 구체적이면 그쪽이 우선) */ 보이던 이질감 해소. (개별 화면 scoped 스타일이 더 구체적이면 그쪽이 우선) */
input, input,
+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')
+50
View File
@@ -0,0 +1,50 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ADS_ENABLED } from '@/config/features'
// 카카오 애드핏 배너. 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시한다.
const props = defineProps({
unit: { type: String, default: 'DAN-n0I5XRBOwAdSFbEX' },
width: { type: Number, default: 728 },
height: { type: Number, default: 90 },
})
const auth = useAuthStore()
const showAd = computed(() => ADS_ENABLED && !auth.isPremium)
const host = ref(null)
const ADFIT_SRC = '//t1.kakaocdn.net/kas/static/ba.min.js'
onMounted(() => {
if (!showAd.value || !host.value) return
// SPA 에서 동적 삽입 시 애드핏이 인식하도록 ins 를 직접 만들고 로더를 다시 실행한다.
const ins = document.createElement('ins')
ins.className = 'kakao_ad_area'
ins.style.display = 'none'
ins.setAttribute('data-ad-unit', props.unit)
ins.setAttribute('data-ad-width', String(props.width))
ins.setAttribute('data-ad-height', String(props.height))
host.value.appendChild(ins)
const s = document.createElement('script')
s.type = 'text/javascript'
s.src = ADFIT_SRC
s.async = true
host.value.appendChild(s)
})
</script>
<template>
<div v-if="showAd" ref="host" class="ad-banner" :style="{ minHeight: height + 'px' }"></div>
</template>
<style scoped>
.ad-banner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
overflow: hidden; /* 좁은 화면에서 가로 넘침 방지 */
}
</style>
+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>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import { computed, ref, watch } from 'vue'
// 작성자/추천자 아바타. 우선순위: 커스텀(profileImage) > 구글(googlePicture) > 이니셜.
const props = defineProps({
name: { type: String, default: '' },
googlePicture: { type: String, default: '' },
profileImage: { type: String, default: '' },
size: { type: Number, default: 28 },
})
const broken = ref(false)
const src = computed(() => props.profileImage || props.googlePicture || '')
const initial = computed(() => {
const s = (props.name || '').trim()
return s ? s[0].toUpperCase() : '?'
})
// src 가 바뀌면 깨짐 상태 리셋
watch(src, () => { broken.value = false })
</script>
<template>
<span class="user-avatar" :style="{ width: size + 'px', height: size + 'px' }" :title="name">
<img
v-if="src && !broken"
:src="src"
:alt="name"
referrerpolicy="no-referrer"
@error="broken = true"
/>
<span v-else class="ua-initial" :style="{ fontSize: Math.round(size * 0.45) + 'px' }">{{ initial }}</span>
</span>
</template>
<style scoped>
.user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
flex: none;
vertical-align: middle;
background: var(--color-background-mute);
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ua-initial {
font-weight: 700;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: hsla(160, 100%, 37%, 1);
}
</style>
+48 -3
View File
@@ -1,7 +1,9 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router'
import AdBanner from '@/components/AdBanner.vue'
// PC·모바일 웹에서 노출되는 안내 페이지. 실제 이용은 앱(모바일) 또는 PC 데스크톱 클라이언트에서. // PC·모바일 웹에서 노출되는 안내 페이지. 실제 이용은 앱(모바일) 또는 PC 데스크톱 클라이언트에서.
// PC 설치파일은 서버 /download/ 경로(영속 디렉터리)에서 서빙 — nginx 설정 참고. // PC 설치파일은 서버 /download/ 경로(영속 디렉터리)에서 서빙 — nginx 설정 참고.
const PC_DOWNLOAD_URL = '/download/SlimBudget-Setup.exe' const PC_DOWNLOAD_URL = '/download/DonDwaeji-Setup.exe'
// 모바일 앱(APK/스토어) 링크는 준비되면 연결 // 모바일 앱(APK/스토어) 링크는 준비되면 연결
const mobileReady = false const mobileReady = false
</script> </script>
@@ -13,8 +15,14 @@ const mobileReady = false
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p> <p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
<div class="notice"> <div class="notice">
<p class="lead">🖥 PC는 <b>설치형 </b>으로, 📱 모바일은 <b></b>으로 이용해요.</p> <p class="lead">
<p class="sub"> 브라우저에서는 안내만 제공됩니다. 설치 로그인해 주세요.</p> <span class="ln">🖥 PC는 <b>설치 </b>으로,</span>
<span class="ln">📱 모바일은 <b></b>으로 이용해요.</span>
</p>
<p class="sub">
<span class="ln"> 브라우저에서는 안내만 제공됩니다.</span>
<span class="ln">설치한 로그인해 주세요.</span>
</p>
</div> </div>
<div class="dl-group"> <div class="dl-group">
@@ -31,7 +39,18 @@ const mobileReady = false
<li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자까지</span></li> <li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자까지</span></li>
<li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출·차트</span></li> <li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출·차트</span></li>
</ul> </ul>
<nav class="legal-links">
<RouterLink to="/privacy">개인정보처리방침</RouterLink>
<span aria-hidden="true">·</span>
<RouterLink to="/terms">이용약관</RouterLink>
<span aria-hidden="true">·</span>
<RouterLink to="/account-deletion">계정 삭제</RouterLink>
</nav>
</div> </div>
<!-- 하단 광고 (유료 회원 제외) -->
<AdBanner class="web-ad" />
</main> </main>
</template> </template>
@@ -39,13 +58,18 @@ const mobileReady = false
.web-only { .web-only {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1.75rem;
padding: 1.5rem; padding: 1.5rem;
background: background:
radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.12), transparent 60%), radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.12), transparent 60%),
var(--color-background); var(--color-background);
} }
.web-ad {
margin-top: auto; /* 페이지 하단으로 */
}
.card { .card {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
@@ -54,6 +78,8 @@ const mobileReady = false
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 16px; border-radius: 16px;
background: var(--color-background-soft); background: var(--color-background-soft);
/* 한국어는 단어(어절) 단위로 줄바꿈 — 'O요.' 가 혼자 다음 줄로 떨어지는 현상 방지 */
word-break: keep-all;
} }
.brand { .brand {
font-size: 2rem; font-size: 2rem;
@@ -76,6 +102,11 @@ const mobileReady = false
font-size: 0.86rem; font-size: 0.86rem;
opacity: 0.7; opacity: 0.7;
} }
/* 각 구절을 한 덩어리로 — 좁으면 절(span) 경계에서만 줄바꿈, 넓으면 한 줄 */
.lead .ln,
.sub .ln {
display: inline-block;
}
.dl-group { .dl-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -136,4 +167,18 @@ const mobileReady = false
font-size: 0.78rem; font-size: 0.78rem;
opacity: 0.65; opacity: 0.65;
} }
.legal-links {
margin-top: 1.4rem;
display: flex;
justify-content: center;
gap: 0.5rem;
font-size: 0.8rem;
opacity: 0.7;
}
.legal-links a {
color: var(--color-text);
}
.legal-links a:hover {
color: hsla(160, 100%, 37%, 1);
}
</style> </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()
+61 -23
View File
@@ -1,12 +1,43 @@
<script setup> <script setup>
import { RouterLink, useRouter } from 'vue-router' import { computed } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo } from '@/demo'
import { boardLabel } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
// 라우트별 헤더 타이틀(현재 화면 이름)
const TITLES = {
home: '돈돼지 가계부',
'demo-locked': '둘러보기',
'account-entries': '가계부 내역',
'account-stats': '통계',
'account-recurrings': '고정 지출',
'account-wallets': '계좌 관리',
'account-categories': '분류 관리',
'account-budget': '예산 설정',
'account-tags': '태그 관리',
'my-board': '내 글·댓글',
users: '회원 관리',
'admin-tags': '태그 관리(관리자)',
'admin-default-categories': '기본 분류 설정',
settings: '설정',
'settings-account': '계정정보',
'settings-account-edit': '가입정보 변경',
}
const pageTitle = computed(() => {
const n = route.name
if (n === 'board' || n === 'board-detail') return boardLabel(route.params.category)
if (n === 'board-write' || n === 'board-edit') return route.params.id ? '글 수정' : '글쓰기'
return TITLES[n] || '돈돼지 가계부'
})
async function handleLogout() { async function handleLogout() {
await auth.logout() await auth.logout()
@@ -17,20 +48,25 @@ async function handleLogout() {
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<button v-if="auth.isAuthenticated" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()"> <button v-if="auth.isAuthenticated || demo.on" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" /> <path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
</svg> </svg>
</button> </button>
<RouterLink to="/" class="brand"> <h1 class="page-title">{{ pageTitle }}</h1>
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
<span class="brand-text">돈돼지 가계부</span>
</RouterLink>
</div> </div>
<div class="header-right"> <div class="header-right">
<template v-if="auth.isAuthenticated"> <template v-if="auth.isAuthenticated">
<span class="username">{{ auth.user?.name || auth.user?.loginId }}</span> <RouterLink to="/settings/account" class="me" title="계정정보">
<UserAvatar
:name="auth.user?.name || auth.user?.loginId"
:google-picture="auth.user?.googlePicture"
:profile-image="auth.user?.profileImage"
:size="26"
/>
<span class="username">{{ auth.user?.name || auth.user?.loginId }}</span>
</RouterLink>
<IconBtn icon="logout" title="로그아웃" @click="handleLogout" /> <IconBtn icon="logout" title="로그아웃" @click="handleLogout" />
</template> </template>
<IconBtn v-else icon="login" title="로그인" variant="primary" @click="ui.openLogin()" /> <IconBtn v-else icon="login" title="로그인" variant="primary" @click="ui.openLogin()" />
@@ -66,36 +102,38 @@ async function handleLogout() {
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.brand { .page-title {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--color-heading); color: var(--color-heading);
}
.brand:hover {
background: transparent;
}
.brand-mark {
height: 28px;
width: auto;
max-width: 38px;
object-fit: contain;
flex-shrink: 0;
}
.brand-text {
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.me {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
}
.me:hover {
opacity: 0.85;
}
.username { .username {
font-size: 0.9rem; font-size: 0.9rem;
} }
@media (max-width: 480px) {
/* 좁은 폰 화면: 이름 숨기고 아바타만 */
.username {
display: none;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hamburger { .hamburger {
+65 -21
View File
@@ -1,11 +1,17 @@
<script setup> <script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { BOARDS } from '@/constants/boards' import { BOARDS } from '@/constants/boards'
import { demo } from '@/demo'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
const showMenu = computed(() => auth.isAuthenticated || demo.on)
// 유료 전용 메뉴 잠금 배지: 데모이거나, 로그인했지만 무료 회원일 때.
const lockPremium = computed(() => demo.on || (auth.isAuthenticated && !auth.isPremium))
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업. // 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
const icons = { const icons = {
@@ -25,12 +31,15 @@ const icons = {
<template> <template>
<aside class="app-sidebar"> <aside class="app-sidebar">
<div class="drawer-head"> <div class="drawer-head">
<span class="drawer-title">메뉴</span> <RouterLink to="/" class="brand" @click="ui.closeSidebar()">
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
<span class="brand-text">돈돼지 가계부</span>
</RouterLink>
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button> <button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
</div> </div>
<nav class="menu"> <nav class="menu">
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) --> <!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
<template v-if="auth.isAuthenticated"> <template v-if="showMenu">
<RouterLink to="/account/entries" class="menu-item"> <RouterLink to="/account/entries" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
<span>가계부 내역</span> <span>가계부 내역</span>
@@ -38,10 +47,12 @@ const icons = {
<RouterLink to="/account/stats" class="menu-item"> <RouterLink to="/account/stats" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
<span>통계</span> <span>통계</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<RouterLink to="/account/recurrings" class="menu-item"> <RouterLink to="/account/recurrings" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
<span>고정 지출</span> <span>고정 지출</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<RouterLink to="/account/wallets" class="menu-item"> <RouterLink to="/account/wallets" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
@@ -54,10 +65,12 @@ const icons = {
<RouterLink to="/account/budget" class="menu-item"> <RouterLink to="/account/budget" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
<span>예산 설정</span> <span>예산 설정</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<RouterLink to="/account/tags" class="menu-item"> <RouterLink to="/account/tags" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
<span>태그 관리</span> <span>태그 관리</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<!-- 게시판 영역 --> <!-- 게시판 영역 -->
@@ -70,6 +83,11 @@ const icons = {
> >
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" />
<span>{{ b.label }}</span> <span>{{ b.label }}</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
</RouterLink>
<RouterLink v-if="auth.isAuthenticated" to="/my-board" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
<span> ·댓글</span>
</RouterLink> </RouterLink>
</template> </template>
@@ -97,34 +115,55 @@ const icons = {
.app-sidebar { .app-sidebar {
background: var(--color-background-soft); background: var(--color-background-soft);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
padding: 1rem 0; padding: 0 0 1rem;
} }
.drawer-head { .drawer-head {
display: none; display: flex;
align-items: center;
justify-content: space-between;
height: 56px; /* 헤더 높이와 맞춰 상단 정렬 (하단 보더 일치) */
padding: 0 1.1rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 1.15rem;
font-weight: 700;
color: var(--color-heading);
}
.brand:hover {
background: transparent;
}
.brand-mark {
height: 26px;
width: auto;
max-width: 34px;
object-fit: contain;
flex-shrink: 0;
}
.brand-text {
letter-spacing: -0.01em;
white-space: nowrap;
}
.drawer-close {
display: none; /* 데스크톱: 숨김 (모바일 드로어에서만 노출) */
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.app-sidebar { .app-sidebar {
background: var(--color-background); background: var(--color-background);
min-height: 100%; min-height: 100%;
} }
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.5rem;
}
.drawer-title {
font-weight: 700;
}
.drawer-close { .drawer-close {
border: 0; display: inline-flex;
background: transparent;
color: var(--color-text);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
} }
} }
.menu { .menu {
@@ -145,6 +184,11 @@ const icons = {
flex: none; flex: none;
opacity: 0.8; opacity: 0.8;
} }
.lock-badge {
margin-left: auto;
font-size: 0.72rem;
opacity: 0.55;
}
.menu-item.router-link-exact-active .menu-icon { .menu-item.router-link-exact-active .menu-icon {
opacity: 1; opacity: 1;
} }
+1
View File
@@ -27,6 +27,7 @@ const ICONS = {
refresh: ['M23 4v6h-6', 'M1 20v-6h6', 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10', 'M1 14l4.64 4.36A9 9 0 0 0 20.49 15'], refresh: ['M23 4v6h-6', 'M1 20v-6h6', 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10', 'M1 14l4.64 4.36A9 9 0 0 0 20.49 15'],
filter: ['M22 3H2l8 9.46V19l4 2v-8.54L22 3z'], filter: ['M22 3H2l8 9.46V19l4 2v-8.54L22 3z'],
menu: ['M3 12h18', 'M3 6h18', 'M3 18h18'], menu: ['M3 12h18', 'M3 6h18', 'M3 18h18'],
list: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'],
back: ['M19 12H5', 'M12 19l-7-7 7-7'], back: ['M19 12H5', 'M12 19l-7-7 7-7'],
logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9'], logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'], login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'],
+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')
},
}
+18
View File
@@ -3,3 +3,21 @@
// 백엔드(/api/auth/login, /api/auth/signup)는 비상용으로 그대로 유지 — // 백엔드(/api/auth/login, /api/auth/signup)는 비상용으로 그대로 유지 —
// 되돌리려면 이 값을 true 로 바꾸면 아이디 로그인/회원가입이 다시 노출된다. // 되돌리려면 이 값을 true 로 바꾸면 아이디 로그인/회원가입이 다시 노출된다.
export const ID_LOGIN_ENABLED = false export const ID_LOGIN_ENABLED = false
// 카드 결제 알림 자동인식(NotificationListenerService) 기능.
// 스토어 첫 출시에서는 Google Play 알림 접근 정책 심사 회피를 위해 비활성화.
// (AndroidManifest 의 CardNotifListenerService 선언도 함께 주석 처리되어 있음)
export const CARD_NOTIF_ENABLED = false
// 인앱결제(유료 구독) 기능.
// 첫 출시는 무료 앱으로 등록 — 실제 Google Play Billing 연동(결제 플러그인 +
// 서버 영수증 검증) 전까지는 구매 흐름이 테스트 스텁이라, 활성화하면 결제 없이
// 프리미엄이 부여되는 문제가 있다. 그래서 구매 진입점을 모두 숨긴다.
// 실연동 완료 후 true 로 바꾸면 업그레이드/정기결제 관리 화면이 다시 노출된다.
export const BILLING_ENABLED = false
// 광고(카카오 애드핏) 노출 기능.
// 첫 출시는 광고 미승인 상태 + 데이터 안전 양식 단순화를 위해 비활성화.
// (광고 식별자 수집/제3자 공유가 사라져 심사가 단순해짐)
// 애드핏 앱 광고단위 승인 후 true 로 바꾸면 무료 회원에게 광고가 다시 노출된다.
export const ADS_ENABLED = false
+59 -18
View File
@@ -1,14 +1,17 @@
// 회원가입 약관 본문. 모달에서 '전문 보기'로 노출. // 이용약관 / 개인정보처리방침 본문. 가입 모달 '전문 보기' 및 /terms, /privacy 페이지에서 노출.
// 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리) // 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리)
export const SERVICE_NAME = '돈돼지 가계부' export const SERVICE_NAME = '돈돼지 가계부'
export const EFFECTIVE_DATE = '2026-06-28'
// 개인정보 보호 문의 — 운영자 연락처(필요 시 변경)
export const CONTACT_EMAIL = 'sbsb401824@gmail.com'
export const TERMS_OF_SERVICE = `제1조 (목적) export const TERMS_OF_SERVICE = `제1조 (목적)
이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다. 이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
제2조 (정의) 제2조 (정의)
1. "회원"이란 본 약관에 동의하고 서비스에 가입하여 이용하는 자를 말합니다. 1. "회원"이란 본 약관에 동의하고 서비스에 가입하여 이용하는 자를 말합니다.
2. "계정"이란 회원 식별과 서비스 이용을 위해 회원이 등록한 아이디와 비밀번호를 말합니다. 2. "계정"이란 회원 식별과 서비스 이용을 위해 회원이 등록(또는 소셜 로그인으로 연동)한 식별 정보를 말합니다.
제3조 (약관의 효력 및 변경) 제3조 (약관의 효력 및 변경)
1. 본 약관은 회원이 가입 시 동의함으로써 효력이 발생합니다. 1. 본 약관은 회원이 가입 시 동의함으로써 효력이 발생합니다.
@@ -26,33 +29,71 @@ export const TERMS_OF_SERVICE = `제1조 (목적)
제6조 (게시물) 제6조 (게시물)
회원이 게시판 등에 작성한 게시물의 권리와 책임은 작성자에게 있으며, 서비스 운영이나 타인의 권리를 침해하는 게시물은 사전 통지 없이 제한될 수 있습니다. 회원이 게시판 등에 작성한 게시물의 권리와 책임은 작성자에게 있으며, 서비스 운영이나 타인의 권리를 침해하는 게시물은 사전 통지 없이 제한될 수 있습니다.
제7조 (계약 해지) 제7조 (유료 서비스 및 결제)
회원은 언제든지 탈퇴를 요청할 수 있으며, 탈퇴 시 회원의 데이터는 관련 법령에서 정한 경우를 제외하고 지체 없이 삭제됩니다. 1. 서비스는 일부 기능을 유료(프리미엄) 구독으로 제공할 수 있습니다.
2. 모바일 앱의 결제는 Google Play 등 앱 마켓의 인앱결제를 통해 이루어지며, 결제·환불·정기결제(자동 갱신)의 처리 및 정책은 해당 마켓의 규정을 따릅니다.
3. 구독은 만료일까지 이용할 수 있으며, 해지하더라도 남은 기간 동안 이용이 유지된 후 무료로 전환됩니다.
4. 청약철회 및 환불은 관련 법령과 앱 마켓 정책에 따릅니다.
제8조 (면책) 제8조 (광고)
서비스는 천재지변, 회원의 귀책사유, 제3자 서비스(시세 제공처 등)의 장애 등 합리적으로 통제할 수 없는 사유로 인한 손해에 대하여 책임을 지지 않습니다. 서비스는 무료 회원에게 광고를 표시할 수 있으며, 유료(프리미엄) 회원에게는 광고를 표시하지 않습니다.
제9조 (계약 해지)
회원은 언제든지 앱 내 '회원 탈퇴' 기능으로 탈퇴할 수 있으며, 탈퇴 시 회원의 데이터는 관련 법령에서 정한 경우를 제외하고 지체 없이 삭제됩니다.
제10조 (면책)
서비스는 천재지변, 회원의 귀책사유, 제3자 서비스(시세 제공처, 앱 마켓, 광고 등)의 장애 등 합리적으로 통제할 수 없는 사유로 인한 손해에 대하여 책임을 지지 않습니다.
부칙 부칙
본 약관은 가입 시점부터 적용됩니다.` 본 약관은 ${EFFECTIVE_DATE}부터 적용됩니다.`
export const PRIVACY_POLICY = `${SERVICE_NAME}는 회원가입 및 서비스 제공을 위해 아래와 같이 개인정보를 수집·이용합니다. export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자의 개인정보를 중요하게 생각하며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 본 방침은 서비스가 어떤 정보를 어떻게 수집·이용·보관하는지 안내합니다.
1. 수집 항목 1. 수집하는 개인정보 항목
- 필수: 아이디, 비밀번호(암호화 저장), 이름 가) 회원가입·계정
- 선택: 이메일 - 로컬 가입: 아이디, 비밀번호(단방향 암호화 저장), 이름, 이메일(선택)
- 서비스 이용 과정에서 회원이 직접 입력하는 가계부·자산·거래 내역 등 - 구글 로그인: 구글 계정 식별자, 이름, 이메일, 프로필 사진(구글 제공 범위)
나) 서비스 이용 과정에서 회원이 직접 입력·등록하는 정보
- 가계부·계좌·자산·예산·거래 내역 등 가계부 데이터
- 게시판 글·댓글, 직접 업로드한 프로필 사진(선택)
다) 자동으로 생성·수집되는 정보
- 서비스 이용 기록, 기기·브라우저 정보(부정 이용 방지 및 향후 광고 도입 시 광고 제공 목적)
- 인앱결제 시 구매 토큰 및 구독 상태(결제 검증·구독 관리 목적)
2. 수집·이용 목적 2. 수집·이용 목적
- 회원 식별 및 로그인 등 계정 관리 - 회원 식별 및 로그인 등 계정 관리
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관 - 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
- (향후 도입 시) 무료 회원 대상 광고 제공
- 서비스 운영, 문의 응대 및 부정 이용 방지 - 서비스 운영, 문의 응대 및 부정 이용 방지
3. 보유 및 이용 기간 3. 처리위탁 및 제3자
- 회원 탈퇴 시까지 보유하며, 탈퇴 시 지체 없이 파기합니다. 서비스는 기능 제공을 위해 아래 사업자의 서비스를 이용하며, 필요한 범위에서 정보가 처리될 수 있습니다.
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR)
- Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
4. 광고
- 현재 서비스에는 광고가 표시되지 않습니다.
- 향후 무료 회원을 대상으로 광고가 도입될 수 있으며, 이 경우 광고 식별·노출을 위해 기기 정보 등이 광고 사업자에 의해 처리될 수 있습니다. 유료(프리미엄) 회원에게는 광고가 표시되지 않습니다.
5. 결제 정보
- 결제는 Google Play 등 앱 마켓의 인앱결제로 처리되며, 카드번호 등 결제수단 정보는 서비스가 저장하지 않습니다.
- 서비스는 결제 검증·구독 관리를 위한 구매 토큰과 구독 상태(상품, 만료일 등)만 보관합니다.
6. 보유 및 이용 기간
- 회원 탈퇴 시까지 보유하며, 탈퇴 시 회원의 데이터(가계부·게시물·포인트·결제기록 등)를 지체 없이 파기합니다.
- 다만 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다. - 다만 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.
4. 동의 거부 권리 및 불이익 7. 이용자의 권리
- 회원은 개인정보 수집·이용 동의를 거부할 권리가 있습니다. - 회원은 자신의 개인정보에 대해 열람·정정·삭제·처리정지를 요청할 수 있으며, 앱 내 '회원 탈퇴'로 계정과 데이터를 삭제할 수 있습니다.
- 다만 필수 항목에 동의하지 않을 경우 회원가입 및 서비스 이용이 제한됩니다. - 개인정보 수집·이용 동의를 거부할 권리가 있으나, 필수 항목 미동의 시 회원가입 및 서비스 이용이 제한됩니다.
비밀번호는 복호화가 불가능한 방식으로 암호화하여 저장되며, 운영자도 원문을 확인할 수 없습니다.` 8. 안전성 확보 조치
- 비밀번호는 복호화가 불가능한 방식으로 암호화하여 저장하며, 운영자도 원문을 확인할 수 없습니다.
- 전송 구간 암호화(HTTPS) 등 합리적인 보호조치를 적용합니다.
9. 개인정보 보호 문의
- 문의: ${CONTACT_EMAIL}
본 개인정보처리방침은 ${EFFECTIVE_DATE}부터 적용됩니다.`
+150
View File
@@ -0,0 +1,150 @@
// 둘러보기(데모) 모드 — 실제 화면 컴포넌트를 그대로 쓰되 API 만 더미로 응답한다.
// demo.on 이 true 면 accountApi 의 읽기는 아래 더미를, 쓰기는 block()(안내 메시지)으로 응답.
import { reactive } from 'vue'
export const demo = reactive({ on: false })
export function enterDemo() {
demo.on = true
}
export function exitDemo() {
demo.on = false
}
// ===== 더미 데이터 (백엔드 응답 DTO 형태와 동일) =====
const WALLETS = [
{ id: 1, type: 'BANK', name: '신한 주거래', issuer: '신한', accountNumber: null, openingBalance: 0, balance: 2540000, manualValuation: false },
{ id: 2, type: 'BANK', name: '주택청약', issuer: '국민', accountNumber: null, openingBalance: 0, balance: 5000000, manualValuation: false },
{ id: 3, type: 'CASH', name: '현금', openingBalance: 0, balance: 120000, manualValuation: false },
{ id: 4, type: 'CARD', name: '삼성카드', issuer: '삼성', cardType: 'CREDIT', openingBalance: 0, balance: -340000, manualValuation: false },
{
id: 5, type: 'INVEST', name: '연금저축', manualValuation: true,
investedAmount: 7000000, currentValue: 8200000, balance: 8200000,
valuationGain: 1200000, deposit: 0, stockValue: 8200000,
},
]
const CATEGORIES = [
// 지출 대분류/소분류
{ id: 10, type: 'EXPENSE', name: '식비', parentId: null },
{ id: 11, type: 'EXPENSE', name: '점심', parentId: 10 },
{ id: 12, type: 'EXPENSE', name: '저녁', parentId: 10 },
{ id: 13, type: 'EXPENSE', name: '카페/간식', parentId: 10 },
{ id: 20, type: 'EXPENSE', name: '교통', parentId: null },
{ id: 21, type: 'EXPENSE', name: '대중교통', parentId: 20 },
{ id: 22, type: 'EXPENSE', name: '택시', parentId: 20 },
{ id: 30, type: 'EXPENSE', name: '주거/통신', parentId: null },
{ id: 31, type: 'EXPENSE', name: '월세', parentId: 30 },
{ id: 32, type: 'EXPENSE', name: '통신비', parentId: 30 },
{ id: 40, type: 'EXPENSE', name: '문화/여가', parentId: null },
{ id: 41, type: 'EXPENSE', name: 'OTT/구독', parentId: 40 },
{ id: 50, type: 'EXPENSE', name: '쇼핑', parentId: null },
// 수입
{ id: 60, type: 'INCOME', name: '급여', parentId: null },
{ id: 61, type: 'INCOME', name: '용돈', parentId: null },
]
// 이번 달(현 시점) 기준 샘플 내역
function ymd(day) {
const now = new Date()
const m = String(now.getMonth() + 1).padStart(2, '0')
return `${now.getFullYear()}-${m}-${String(day).padStart(2, '0')}`
}
function E(id, day, type, category, amount, memo, walletId, walletName, opt = {}) {
return {
id, entryDate: ymd(day), type, category, amount, memo,
walletId, walletName, toWalletId: null, toWalletName: null,
installmentMonths: null, pending: !!opt.pending, tags: opt.tags || [],
}
}
const ENTRIES = [
E(101, 27, 'EXPENSE', '점심', 9000, '김밥천국', 4, '삼성카드'),
E(102, 27, 'EXPENSE', '카페/간식', 5300, '스타벅스', 4, '삼성카드'),
E(103, 27, 'EXPENSE', '대중교통', 2800, '지하철', 3, '현금'),
E(104, 27, 'EXPENSE', '쇼핑', 25200, '다이소', 4, '삼성카드', { pending: true }),
E(105, 25, 'INCOME', '급여', 4600000, '6월 급여', 1, '신한 주거래'),
E(106, 25, 'EXPENSE', '통신비', 45300, '휴대폰 요금', 1, '신한 주거래'),
E(107, 23, 'EXPENSE', 'OTT/구독', 17000, '넷플릭스', 4, '삼성카드', { tags: ['구독'] }),
E(108, 23, 'EXPENSE', '저녁', 23000, '배달', 4, '삼성카드'),
E(109, 20, 'EXPENSE', '쇼핑', 285000, '마트 장보기', 4, '삼성카드'),
E(110, 18, 'EXPENSE', '저녁', 62000, '친구 외식', 4, '삼성카드', { tags: ['데이트'] }),
E(111, 15, 'EXPENSE', '택시', 28000, '심야 택시', 4, '삼성카드'),
E(112, 12, 'EXPENSE', '쇼핑', 320000, '의류 구매', 4, '삼성카드'),
E(113, 8, 'EXPENSE', '대중교통', 55000, '교통카드 충전', 3, '현금'),
E(114, 5, 'EXPENSE', '카페/간식', 18500, '베이커리', 4, '삼성카드'),
E(115, 1, 'EXPENSE', '월세', 600000, '6월 월세', 1, '신한 주거래'),
]
const RECURRINGS = [
{ id: 201, title: '월세', type: 'EXPENSE', amount: 600000, category: '월세', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: null, monthOfYear: null, startDate: ymd(1), endDate: null, lastRunDate: null, nextDate: ymd(1), active: true },
{ id: 202, title: '통신비', type: 'EXPENSE', amount: 45300, category: '통신비', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 15, dayOfWeek: null, monthOfYear: null, startDate: ymd(15), endDate: null, lastRunDate: null, nextDate: ymd(15), active: true },
{ id: 203, title: '넷플릭스', type: 'EXPENSE', amount: 17000, category: 'OTT/구독', memo: null, walletId: 4, walletName: '삼성카드', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 23, dayOfWeek: null, monthOfYear: null, startDate: ymd(23), endDate: null, lastRunDate: null, nextDate: ymd(23), active: true },
{ id: 204, title: '급여', type: 'INCOME', amount: 4600000, category: '급여', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 25, dayOfWeek: null, monthOfYear: null, startDate: ymd(25), endDate: null, lastRunDate: null, nextDate: ymd(25), active: true },
]
const QUICK = [
{ id: 301, label: '점심', type: 'EXPENSE', category: '점심', amount: 9000, memo: '점심', walletId: 4, walletName: '삼성카드' },
{ id: 302, label: '커피', type: 'EXPENSE', category: '카페/간식', amount: 5300, memo: '커피', walletId: 4, walletName: '삼성카드' },
{ id: 303, label: '대중교통', type: 'EXPENSE', category: '대중교통', amount: 2800, memo: '교통', walletId: 3, walletName: '현금' },
]
const TAGS = [
{ id: 401, name: '구독' },
{ id: 402, name: '데이트' },
{ id: 403, name: '경조사' },
]
// 해당 연·월 내역만 (월 이동 반영)
function inMonth(e, year, month) {
if (!year || !month) return true
return e.entryDate.startsWith(`${year}-${String(month).padStart(2, '0')}`)
}
// 검색·필터 적용
function filterEntries(p = {}) {
let rows = ENTRIES.filter((e) => inMonth(e, p.year, p.month))
if (p.type) rows = rows.filter((e) => e.type === p.type)
if (p.category) rows = rows.filter((e) => e.category === p.category)
if (p.walletId) rows = rows.filter((e) => String(e.walletId) === String(p.walletId))
if (p.keyword) {
const k = String(p.keyword).trim()
rows = rows.filter((e) => (e.memo || '').includes(k) || (e.category || '').includes(k))
}
if (p.tagId) {
const t = TAGS.find((x) => String(x.id) === String(p.tagId))
if (t) rows = rows.filter((e) => (e.tags || []).includes(t.name))
}
return rows
}
function summaryOf(p = {}) {
let income = 0, expense = 0
for (const e of ENTRIES) {
if (!inMonth(e, p.year, p.month)) continue
if (e.type === 'INCOME') income += e.amount
else if (e.type === 'EXPENSE') expense += e.amount
}
return { totalIncome: income, totalExpense: expense, balance: income - expense }
}
const NETWORTH = { totalAssets: 15860000, totalLiabilities: 340000, netWorth: 15520000 }
function ok(data) {
// 깊은 복제로 화면에서 수정해도 원본 더미는 보존
return Promise.resolve(JSON.parse(JSON.stringify(data)))
}
function block() {
return Promise.reject({ response: { data: { message: '둘러보기 모드에서는 저장되지 않습니다. 로그인 후 이용해 주세요.' } } })
}
// 읽기 응답 / 쓰기 차단
export const demoApi = {
list: (params) => ok(filterEntries(params)),
summary: (params) => ok(summaryOf(params)),
wallets: () => ok(WALLETS),
netWorth: () => ok(NETWORTH),
categories: () => ok(CATEGORIES),
recurrings: () => ok(RECURRINGS),
quickEntries: () => ok(QUICK),
pendingCount: () => ok({ count: 1 }),
tags: () => ok(TAGS),
walletEntries: () => ok([]),
block,
}
+10
View File
@@ -8,6 +8,11 @@ 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'
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
initTheme()
// 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관). // 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관).
// confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용. // confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용.
@@ -18,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)
+26
View File
@@ -0,0 +1,26 @@
import { Capacitor } from '@capacitor/core'
// 인앱 결제(Google Play) 헬퍼.
// 결제는 Android 앱에서만 가능 — 웹/데스크톱(PC)은 앱 다운로드로 유도한다.
export const billing = {
// Capacitor 네이티브(안드로이드) 여부
isNative() {
return Capacitor.isNativePlatform()
},
/**
* 상품 구매 → 구매 토큰 반환.
* 스캐폴드(테스트): 실제 스토어 호출 없이 test- 토큰을 만들어 백엔드 검증 흐름을 태운다.
*
* TODO(실연동): Google Play Billing 플러그인(cordova-plugin-purchase 등)을 연결해
* 실제 구매 후 purchaseToken/orderId 를 받아 반환하도록 교체.
*/
async purchase(productId) {
const rand = (globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.floor(Math.random() * 1e9)}`)
return {
productId,
purchaseToken: `test-${productId}-${rand}`,
orderId: `TEST-${rand}`,
}
},
}
+124
View File
@@ -0,0 +1,124 @@
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences'
// 로컬 알림(앱 전용) — 가계부 기록 리마인더 + 구독 만료 임박 알림.
// 서버 푸시(FCM) 없이 기기에서 예약되며, 앱을 켜지 않아도 예약 시간에 표시된다.
const PREF_ENABLED = 'notif_enabled'
const PREF_HOUR = 'notif_hour'
const ENTRY_BASE = 5000 // 가계부 리마인더 id 5000~5006 (7일 롤링)
const ENTRY_DAYS = 7
const EXPIRY_ID = 5100 // 구독 만료 임박 id
let LN = null
async function ln() {
if (!Capacitor.isNativePlatform()) return null
if (!LN) LN = (await import('@capacitor/local-notifications')).LocalNotifications
return LN
}
async function cancelIds(ids) {
const n = await ln()
if (!n) return
try {
await n.cancel({ notifications: ids.map((id) => ({ id })) })
} catch {
/* 무시 */
}
}
export const reminders = {
isNative() {
return Capacitor.isNativePlatform()
},
async getSettings() {
const e = (await Preferences.get({ key: PREF_ENABLED })).value
const h = (await Preferences.get({ key: PREF_HOUR })).value
return { enabled: e === '1', hour: h != null ? Number(h) : 21 }
},
async setSettings({ enabled, hour }) {
await Preferences.set({ key: PREF_ENABLED, value: enabled ? '1' : '0' })
if (hour != null) await Preferences.set({ key: PREF_HOUR, value: String(hour) })
},
/** 알림 권한 요청 (Android 13+). 허용되면 true */
async ensurePermission() {
const n = await ln()
if (!n) return false
let p = await n.checkPermissions()
if (p.display !== 'granted') p = await n.requestPermissions()
return p.display === 'granted'
},
/** 가계부 리마인더 — 향후 7일 매일 예약(오늘 이미 기록했으면 오늘은 제외) */
async scheduleEntryReminder(hasEntryToday = false) {
const n = await ln()
if (!n) return
await cancelIds(Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i))
const { enabled, hour } = await this.getSettings()
if (!enabled) return
const now = new Date()
const list = []
for (let i = 0; i < ENTRY_DAYS; i++) {
const at = new Date(now)
at.setDate(now.getDate() + i)
at.setHours(hour, 0, 0, 0)
if (at <= now) continue
if (i === 0 && hasEntryToday) continue
list.push({
id: ENTRY_BASE + i,
title: '돈돼지 가계부',
body: '오늘 가계부 기록을 잊지 않으셨나요? 📝',
schedule: { at },
})
}
if (list.length) {
try {
await n.schedule({ notifications: list })
} catch {
/* 무시 */
}
}
},
/** 오늘 기록을 완료하면 오늘자 리마인더만 취소 */
async clearTodayReminder() {
await cancelIds([ENTRY_BASE])
},
/**
* 구독 만료 임박 알림 — 해지(자동갱신 off) 상태에서 만료 3일 전 예약.
* sub: { premium, autoRenew, expiresAt }
*/
async scheduleExpiryReminder(sub) {
const n = await ln()
if (!n) return
await cancelIds([EXPIRY_ID])
const { enabled } = await this.getSettings()
if (!enabled || !sub || !sub.premium || sub.autoRenew || !sub.expiresAt) return
const exp = new Date(sub.expiresAt)
if (Number.isNaN(exp.getTime())) return
const at = new Date(exp.getTime() - 3 * 24 * 60 * 60 * 1000)
at.setHours(10, 0, 0, 0)
if (at <= new Date()) return
try {
await n.schedule({
notifications: [{
id: EXPIRY_ID,
title: '돈돼지 가계부',
body: `프리미엄 구독이 ${exp.getFullYear()}.${exp.getMonth() + 1}.${exp.getDate()}에 만료돼요. 계속 이용하려면 갱신해 주세요.`,
schedule: { at },
}],
})
} catch {
/* 무시 */
}
},
/** 알림 전체 해제 (토글 off) */
async cancelAll() {
await cancelIds([...Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i), EXPIRY_ID])
},
}
+61 -4
View File
@@ -2,6 +2,10 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo } from '@/demo'
// 둘러보기(데모)에서 더미로 진입 가능한 화면
const DEMO_ALLOWED = new Set(['account-entries', 'account-recurrings', 'account-wallets', 'account-categories'])
// Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전 // Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전
const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent) const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
@@ -14,6 +18,40 @@ const router = createRouter({
name: 'home', name: 'home',
component: HomeView, component: HomeView,
}, },
{
// 둘러보기(데모)에서 미지원(로그인 필요) 화면 진입 시 — 잠금 안내 (공개)
path: '/demo-locked',
name: 'demo-locked',
component: () => import('../views/DemoLockedView.vue'),
},
{
// 유료 멤버십 안내/업그레이드 (로그인 필요, 무료 회원도 접근 가능)
path: '/upgrade',
name: 'upgrade',
component: () => import('../views/UpgradeView.vue'),
meta: { requiresAuth: true },
},
{
// 개인정보처리방침 (공개 — 웹에서도 접근, 앱 마켓 정책 URL)
path: '/privacy',
name: 'privacy',
component: () => import('../views/LegalView.vue'),
meta: { public: true, doc: 'privacy' },
},
{
// 이용약관 (공개)
path: '/terms',
name: 'terms',
component: () => import('../views/LegalView.vue'),
meta: { public: true, doc: 'terms' },
},
{
// 계정·데이터 삭제 안내 (공개 — 앱 마켓 데이터 삭제 정책 URL)
path: '/account-deletion',
name: 'account-deletion',
component: () => import('../views/AccountDeletionView.vue'),
meta: { public: true },
},
{ {
path: '/users', path: '/users',
name: 'users', name: 'users',
@@ -28,6 +66,12 @@ const router = createRouter({
component: () => import('../views/board/BoardListView.vue'), component: () => import('../views/board/BoardListView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/my-board',
name: 'my-board',
component: () => import('../views/board/MyBoardView.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/board/:category(community|saving|tips)/write', path: '/board/:category(community|saving|tips)/write',
name: 'board-write', name: 'board-write',
@@ -75,13 +119,13 @@ const router = createRouter({
path: '/account/stats', path: '/account/stats',
name: 'account-stats', name: 'account-stats',
component: () => import('../views/account/AccountDashboardView.vue'), component: () => import('../views/account/AccountDashboardView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresPremium: true },
}, },
{ {
path: '/account/tags', path: '/account/tags',
name: 'account-tags', name: 'account-tags',
component: () => import('../views/account/AccountTagView.vue'), component: () => import('../views/account/AccountTagView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresPremium: true },
}, },
{ {
path: '/account/wallets', path: '/account/wallets',
@@ -99,13 +143,13 @@ const router = createRouter({
path: '/account/budget', path: '/account/budget',
name: 'account-budget', name: 'account-budget',
component: () => import('../views/account/BudgetView.vue'), component: () => import('../views/account/BudgetView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresPremium: true },
}, },
{ {
path: '/account/recurrings', path: '/account/recurrings',
name: 'account-recurrings', name: 'account-recurrings',
component: () => import('../views/account/RecurringView.vue'), component: () => import('../views/account/RecurringView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresPremium: true },
}, },
// 설정 (앱 하단 내비게이션 → 설정) // 설정 (앱 하단 내비게이션 → 설정)
{ {
@@ -132,6 +176,15 @@ const router = createRouter({
// 전역 네비게이션 가드 // 전역 네비게이션 가드
router.beforeEach((to, from) => { router.beforeEach((to, from) => {
const auth = useAuthStore() const auth = useAuthStore()
// 둘러보기(데모): 지원 화면은 더미로 진입, 그 외 보호 화면은 잠금 안내로
if (demo.on && !auth.isAuthenticated) {
if (to.name === 'home') return { name: 'account-entries' }
if (to.meta.requiresAuth) {
if (DEMO_ALLOWED.has(to.name)) return true
return { name: 'demo-locked', query: { from: to.name || '' } }
}
return true // 공개 라우트(잠금 안내 등)
}
// 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈 // 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈
if (to.meta.requiresAuth && !auth.isAuthenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
const ui = useUiStore() const ui = useUiStore()
@@ -142,6 +195,10 @@ router.beforeEach((to, from) => {
if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') { if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') {
return { name: 'home' } return { name: 'home' }
} }
// 유료 전용 페이지 — 무료 회원은 업그레이드 안내로
if (to.meta.requiresPremium && !auth.isPremium) {
return { name: 'upgrade' }
}
}) })
// 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면 // 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면
+19 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { authApi } from '@/api/authApi' import { authApi } from '@/api/authApi'
import { exitDemo } from '@/demo'
// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다. // 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다.
// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다. // http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다.
@@ -14,6 +15,9 @@ export const useAuthStore = defineStore('auth', () => {
const ready = ref(false) const ready = ref(false)
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'ADMIN')
// 유료(PREMIUM) 멤버십 여부. 관리자는 항상 유료 기능 사용 가능.
const isPremium = computed(() => isAdmin.value || user.value?.plan === 'PREMIUM')
// localStorage 동기화 (http.js 가 동기로 토큰을 읽음) // localStorage 동기화 (http.js 가 동기로 토큰을 읽음)
function mirrorLocal() { function mirrorLocal() {
@@ -59,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.login({ loginId, password, rememberMe }) const res = await authApi.login({ loginId, password, rememberMe })
token.value = res.token token.value = res.token
user.value = res.member user.value = res.member
exitDemo()
await persist() await persist()
return res return res
} }
@@ -68,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.googleLogin({ idToken, rememberMe }) const res = await authApi.googleLogin({ idToken, rememberMe })
token.value = res.token token.value = res.token
user.value = res.member user.value = res.member
exitDemo()
await persist() await persist()
return res return res
} }
@@ -99,13 +105,25 @@ export const useAuthStore = defineStore('auth', () => {
await persist() await persist()
} }
// 서버에서 전체 프로필(아바타·포인트 포함)을 받아 병합 — 재로그인 없이 최신화
async function refreshProfile() {
if (!token.value) return
try {
const me = await authApi.profile()
await applyUser(me)
} catch {
// 실패해도 캐시된 사용자 정보 유지
}
}
async function clear() { async function clear() {
exitDemo()
token.value = '' token.value = ''
user.value = null user.value = null
await persist() await persist()
} }
return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear } return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
}) })
function safeParse(value) { function safeParse(value) {
+9
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { authApi } from '@/api/authApi' import { authApi } from '@/api/authApi'
import { getStoredTheme, setTheme as persistTheme } from '@/theme'
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등) // 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
@@ -28,6 +29,13 @@ export const useUiStore = defineStore('ui', () => {
sidebarOpen.value = false sidebarOpen.value = false
} }
// 테마 (system | light | dark)
const theme = ref(getStoredTheme())
function setTheme(t) {
theme.value = t
persistTheme(t)
}
// 비밀번호 변경 모달 // 비밀번호 변경 모달
const passwordOpen = ref(false) const passwordOpen = ref(false)
function openPassword() { function openPassword() {
@@ -63,5 +71,6 @@ export const useUiStore = defineStore('ui', () => {
signupEnabled, loadSignupEnabled, signupEnabled, loadSignupEnabled,
sidebarOpen, toggleSidebar, closeSidebar, sidebarOpen, toggleSidebar, closeSidebar,
passwordOpen, openPassword, closePassword, passwordOpen, openPassword, closePassword,
theme, setTheme,
} }
}) })
+34
View File
@@ -0,0 +1,34 @@
// 테마(라이트/다크/시스템) 적용 — <html data-theme> 속성으로 제어.
// light/dark : 명시 적용, system : 속성 제거 → OS prefers-color-scheme 따름
const KEY = 'theme'
export function getStoredTheme() {
try {
return localStorage.getItem(KEY) || 'system'
} catch {
return 'system'
}
}
export function applyTheme(theme) {
const el = document.documentElement
if (theme === 'light' || theme === 'dark') {
el.setAttribute('data-theme', theme)
} else {
el.removeAttribute('data-theme') // system
}
}
export function setTheme(theme) {
try {
localStorage.setItem(KEY, theme)
} catch {
/* 저장 실패 무시 */
}
applyTheme(theme)
}
// 앱 시작 시 저장값 즉시 적용 (마운트 전 호출 → 깜빡임 방지)
export function initTheme() {
applyTheme(getStoredTheme())
}
+55
View File
@@ -0,0 +1,55 @@
// 프로필 아바타 유틸 — 표시 우선순위(커스텀 > 구글 > 이니셜)와 업로드용 다운스케일.
/**
* 표시할 아바타 이미지 소스를 고른다.
* 우선순위: 사용자 지정(profileImage) > 구글 아바타(googlePicture) > null(이니셜 폴백)
*/
export function avatarSrc(user) {
if (!user) return null
return user.profileImage || user.googlePicture || null
}
/** 이름/아이디에서 이니셜(첫 글자) 한 글자 — 이미지 없을 때 폴백 */
export function avatarInitial(user) {
const s = (user?.name || user?.loginId || '').trim()
return s ? s[0].toUpperCase() : '?'
}
/**
* 업로드용으로 이미지 파일을 정사각형으로 크롭·축소해 JPEG data URL 로 변환한다.
* (서버/로컬 저장 용량을 작게 — 기본 192px)
*/
export function fileToAvatarDataUrl(file, size = 192, quality = 0.85) {
return new Promise((resolve, reject) => {
if (!file || !file.type?.startsWith('image/')) {
reject(new Error('이미지 파일이 아닙니다.'))
return
}
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
// 중앙 정사각형 크롭(cover)
const min = Math.min(img.width, img.height)
const sx = (img.width - min) / 2
const sy = (img.height - min) / 2
ctx.drawImage(img, sx, sy, min, min, 0, 0, size, size)
const dataUrl = canvas.toDataURL('image/jpeg', quality)
URL.revokeObjectURL(url)
resolve(dataUrl)
} catch (e) {
URL.revokeObjectURL(url)
reject(e)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('이미지를 읽지 못했습니다.'))
}
img.src = url
})
}
+154
View File
@@ -0,0 +1,154 @@
// 데이터 백업 — 가계부 전체 데이터를 엑셀(.xlsx)로 내보내기.
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
import { Capacitor } from '@capacitor/core'
import { accountApi } from '@/api/accountApi'
import { appLock } from '@/composables/appLock'
async function safe(promise) {
try {
return await promise
} catch {
return []
}
}
function fileName() {
const d = new Date()
const p = (n) => String(n).padStart(2, '0')
return `돈돼지가계부_백업_${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.xlsx`
}
// 워크북을 플랫폼에 맞게 저장: 웹/PC=다운로드, 앱(네이티브)=파일 저장 후 공유
async function saveWorkbook(XLSX, wb, name) {
if (Capacitor.isNativePlatform()) {
const base64 = XLSX.write(wb, { type: 'base64', bookType: 'xlsx' })
const { Filesystem, Directory } = await import('@capacitor/filesystem')
const { Share } = await import('@capacitor/share')
const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache })
try {
appLock.suppressLock() // 공유 시트 복귀 시 앱 잠금 오작동 방지
await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri })
} catch {
/* 공유 취소 — 파일은 캐시에 저장됨 */
}
} else {
XLSX.writeFile(wb, name)
}
}
export async function exportBackup() {
const XLSX = await import('xlsx')
const [entries, wallets, categories, recurrings, budgets, tags, quick] = await Promise.all([
safe(accountApi.list({})),
safe(accountApi.wallets()),
safe(accountApi.categories()),
safe(accountApi.recurrings()),
// 예산은 월별 — 백업엔 현재 월 예산을 담는다
safe(accountApi.budgets({ year: new Date().getFullYear(), month: new Date().getMonth() + 1 })),
safe(accountApi.tags()),
safe(accountApi.quickEntries()),
])
const catNameById = {}
categories.forEach((c) => (catNameById[c.id] = c.name))
const wb = XLSX.utils.book_new()
const add = (name, rows) => XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), name)
add('내역', entries.map((e) => ({
날짜: e.entryDate, 구분: e.type, 분류: e.category, 금액: e.amount, 메모: e.memo,
계좌: e.walletName, 계좌ID: e.walletId, 입금계좌: e.toWalletName, 입금계좌ID: e.toWalletId,
할부개월: e.installmentMonths, 태그: (e.tags || []).join(','),
})))
add('계좌', wallets.map((w) => ({
ID: w.id, 종류: w.type, 이름: w.name, '은행/카드사': w.issuer, 계좌번호: w.accountNumber,
카드유형: w.cardType, 개시잔액: w.openingBalance, 개시일: w.openingDate, '평가액(수동)': w.currentValue,
})))
add('분류', categories.map((c) => ({
구분: c.type, 이름: c.name, 대분류: c.parentId ? catNameById[c.parentId] || '' : '',
})))
add('고정지출', recurrings.map((r) => ({
제목: r.title, 구분: r.type, 금액: r.amount, 분류: r.category, 메모: r.memo,
계좌: r.walletName, 계좌ID: r.walletId, 입금계좌: r.toWalletName, 입금계좌ID: r.toWalletId, 주기: r.frequency,
: r.dayOfMonth, 요일: r.dayOfWeek, : r.monthOfYear,
시작일: r.startDate, 종료일: r.endDate, 활성: r.active,
})))
add('예산', budgets.map((b) => ({
분류: b.category, 고정: b.fixed, 기준단위: b.baseUnit, 기준금액: b.baseAmount,
: b.daily, : b.weekly, : b.monthly, : b.yearly,
})))
add('태그', tags.map((t) => ({ 이름: t.name })))
add('자주쓰는내역', quick.map((q) => ({
라벨: q.label, 구분: q.type, 분류: q.category, 금액: q.amount, 메모: q.memo, 계좌: q.walletName, 계좌ID: q.walletId,
})))
await saveWorkbook(XLSX, wb, fileName())
}
// ===== 가져오기(복구) — 엑셀 파싱 → 백엔드 복구 =====
export async function importBackup(file) {
const XLSX = await import('xlsx')
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const rows = (name) => {
const ws = wb.Sheets[name]
return ws ? XLSX.utils.sheet_to_json(ws, { defval: null }) : []
}
const str = (v) => (v === null || v === undefined || v === '' ? null : String(v).trim())
const num = (v) => (v === null || v === undefined || v === '' ? null : Number(v))
const bool = (v) => v === true || v === 1 || ['true', 'TRUE', '1', 'Y', '예'].includes(String(v))
const date = (v) => {
if (v === null || v === undefined || v === '') return null
if (typeof v === 'number' && XLSX.SSF) {
const d = XLSX.SSF.parse_date_code(v)
if (d) return `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`
}
return String(v).trim().slice(0, 10) // 'YYYY-MM-DD'
}
const payload = {
wallets: rows('계좌')
.map((r) => ({
oldId: num(r['ID']), type: str(r['종류']), name: str(r['이름']), issuer: str(r['은행/카드사']),
accountNumber: str(r['계좌번호']), cardType: str(r['카드유형']),
openingBalance: num(r['개시잔액']), openingDate: date(r['개시일']), currentValue: num(r['평가액(수동)']),
}))
.filter((w) => w.name && w.type),
categories: rows('분류')
.map((r) => ({ type: str(r['구분']), name: str(r['이름']), parent: str(r['대분류']) }))
.filter((c) => c.name && c.type),
tags: rows('태그').map((r) => str(r['이름'])).filter(Boolean),
entries: rows('내역')
.map((r) => ({
entryDate: date(r['날짜']), type: str(r['구분']), category: str(r['분류']),
amount: num(r['금액']), memo: str(r['메모']), walletId: num(r['계좌ID']), toWalletId: num(r['입금계좌ID']),
installmentMonths: num(r['할부개월']),
tags: str(r['태그']) ? String(r['태그']).split(',').map((s) => s.trim()).filter(Boolean) : [],
}))
.filter((e) => e.entryDate && e.type),
recurrings: rows('고정지출')
.map((r) => ({
title: str(r['제목']), type: str(r['구분']), amount: num(r['금액']), category: str(r['분류']), memo: str(r['메모']),
walletId: num(r['계좌ID']), toWalletId: num(r['입금계좌ID']), frequency: str(r['주기']),
dayOfMonth: num(r['일']), dayOfWeek: num(r['요일']), monthOfYear: num(r['월']),
startDate: date(r['시작일']), endDate: date(r['종료일']), active: bool(r['활성']),
}))
.filter((r) => r.title && r.type),
budgets: rows('예산')
.map((r) => ({
category: str(r['분류']), fixed: bool(r['고정']), baseUnit: str(r['기준단위']), baseAmount: num(r['기준금액']),
daily: num(r['일']), weekly: num(r['주']), monthly: num(r['월']), yearly: num(r['년']),
}))
.filter((b) => b.category),
quickEntries: rows('자주쓰는내역')
.map((r) => ({
label: str(r['라벨']), type: str(r['구분']), category: str(r['분류']),
amount: num(r['금액']), memo: str(r['메모']), walletId: num(r['계좌ID']),
}))
.filter((q) => q.type),
}
await accountApi.restore(payload)
}
+105
View File
@@ -0,0 +1,105 @@
<script setup>
import { useRouter } from 'vue-router'
import { SERVICE_NAME, CONTACT_EMAIL } from '@/constants/terms'
const router = useRouter()
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/')
}
</script>
<template>
<main class="legal">
<header class="legal-head">
<div class="brand">{{ SERVICE_NAME }}</div>
<h1>계정 데이터 삭제</h1>
</header>
<div class="legal-body">
<p>{{ SERVICE_NAME }} 이용자가 본인의 계정과 모든 데이터를 직접 삭제할 있도록 제공합니다.</p>
<h2>앱에서 삭제하기</h2>
<ol>
<li>(또는 PC 클라이언트) 로그인합니다.</li>
<li><b>설정 계정정보</b> 화면으로 이동합니다.</li>
<li> 아래 <b>회원 탈퇴</b> 눌러 안내에 따라 진행합니다.</li>
</ol>
<h2>삭제되는 데이터</h2>
<p>탈퇴 아래 데이터가 <b>지체 없이 영구 삭제</b>되며 복구할 없습니다.</p>
<ul>
<li>계정 정보(아이디·이메일·프로필 )</li>
<li>가계부·계좌·자산·예산·거래 내역 모든 가계부 데이터</li>
<li>게시판 ·댓글·추천 기록</li>
<li>포인트 결제(구독) 기록</li>
</ul>
<p class="muted">, 관련 법령에 따라 보존이 필요한 정보는 해당 기간 동안 보관 파기합니다.</p>
<h2> 없이 삭제를 요청하려면</h2>
<p>앱에 접근할 없는 경우, 아래 이메일로 가입 계정 정보와 함께 삭제를 요청하시면 처리해 드립니다.</p>
<p><b>문의:</b> {{ CONTACT_EMAIL }}</p>
</div>
<div class="legal-foot">
<button type="button" class="legal-back" @click="goBack">돌아가기</button>
</div>
</main>
</template>
<style scoped>
.legal {
max-width: 760px;
margin: 0 auto;
padding: 1.5rem;
}
.legal-head {
text-align: center;
margin-bottom: 1.25rem;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.legal-head h1 {
font-size: 1.4rem;
margin-top: 0.3rem;
}
.legal-body {
font-size: 0.92rem;
line-height: 1.75;
color: var(--color-text);
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.2rem 1.3rem;
}
.legal-body h2 {
font-size: 1.02rem;
margin: 1.2rem 0 0.4rem;
color: var(--color-heading);
}
.legal-body ol,
.legal-body ul {
padding-left: 1.2rem;
}
.legal-body .muted {
font-size: 0.85rem;
opacity: 0.7;
}
.legal-foot {
display: flex;
justify-content: flex-end;
margin-top: 1.25rem;
}
.legal-back {
padding: 0.5rem 1.3rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script setup>
// 둘러보기(데모)에서 미지원(로그인 필요) 화면에 들어왔을 때 안내.
import { useRoute } from 'vue-router'
import { useUiStore } from '@/stores/ui'
const route = useRoute()
const ui = useUiStore()
const LABELS = {
'account-stats': '통계',
'account-budget': '예산 설정',
'account-tags': '태그 관리',
board: '게시판',
'board-detail': '게시판',
settings: '설정',
users: '회원 관리',
}
const label = LABELS[route.query.from] || '이 기능'
function goLogin() {
ui.openLogin('/account')
}
</script>
<template>
<div class="locked">
<div class="lock-big">🔒</div>
<h2>{{ label }} 로그인 이용할 있어요</h2>
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div>
</template>
<style scoped>
.locked {
text-align: center;
padding: 3rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
}
.lock-big {
font-size: 2.4rem;
}
h2 {
margin-top: 0.6rem;
font-size: 1.15rem;
}
.desc {
margin: 0.6rem 0 1.3rem;
font-size: 0.9rem;
opacity: 0.7;
line-height: 1.6;
}
.cta-btn {
width: 100%;
max-width: 320px;
padding: 0.8rem 1rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
</style>
+153 -6
View File
@@ -1,13 +1,21 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { ID_LOGIN_ENABLED } from '@/config/features' import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo'
function startDemo() {
enterDemo()
router.push('/account/entries')
}
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const router = useRouter()
const now = new Date() const now = new Date()
const year = now.getFullYear() const year = now.getFullYear()
@@ -31,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')
} }
@@ -106,10 +129,11 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
const [sum, nw, status, list] = await Promise.all([ const [sum, nw, status, list] = await Promise.all([
accountApi.summary({ year, month }), accountApi.summary({ year, month }),
accountApi.netWorth(), accountApi.netWorth(),
accountApi.budgetStatus({ year, month }), auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
accountApi.list({ year, month }), accountApi.list({ year, month }),
]) ])
summary.value = sum summary.value = sum
@@ -117,6 +141,12 @@ async function load() {
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0) budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0) spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
entries.value = list || [] entries.value = list || []
// 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외)
if (reminders.isNative()) {
const t = new Date()
const today = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`
reminders.scheduleEntryReminder((list || []).some((e) => e.entryDate === today))
}
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.' error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.'
} finally { } finally {
@@ -151,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">
@@ -197,12 +251,14 @@ onMounted(load)
</div> </div>
</div> </div>
<!-- 예산 대비 지출 --> <!-- 예산 대비 지출 (유료 전용 무료 회원은 업그레이드 안내) -->
<div class="card budget-card"> <div class="card budget-card">
<div class="card-head"> <div class="card-head">
<span class="card-title">{{ month }} 예산 대비 지출</span> <span class="card-title">{{ month }} 예산 대비 지출</span>
<RouterLink to="/account/budget" class="more">예산 </RouterLink> <RouterLink :to="auth.isPremium ? '/account/budget' : '/upgrade'" class="more">예산 </RouterLink>
</div> </div>
<RouterLink v-if="!auth.isPremium" to="/upgrade" class="budget-empty">👑 예산 관리는 유료 멤버십 기능이에요 </RouterLink>
<template v-else>
<template v-if="budgetTotal > 0"> <template v-if="budgetTotal > 0">
<div class="budget-bar"> <div class="budget-bar">
<div <div
@@ -218,7 +274,8 @@ onMounted(load)
</span> </span>
</div> </div>
</template> </template>
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink> <RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink>
</template>
</div> </div>
<!-- 일별 수입/지출 캘린더 --> <!-- 일별 수입/지출 캘린더 -->
@@ -298,6 +355,7 @@ onMounted(load)
<div class="cta"> <div class="cta">
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button> <button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
<button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button> <button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
<button type="button" class="btn" @click="startDemo">로그인 없이 둘러보기</button>
</div> </div>
<p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p> <p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
</div> </div>
@@ -327,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;
@@ -692,10 +834,15 @@ onMounted(load)
} }
.cta { .cta {
display: flex; display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem; gap: 0.6rem;
justify-content: center;
margin-top: 1.4rem; margin-top: 1.4rem;
} }
.cta .btn {
width: 100%;
max-width: 260px;
}
.btn { .btn {
padding: 0.6rem 1.4rem; padding: 0.6rem 1.4rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+88
View File
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { TERMS_OF_SERVICE, PRIVACY_POLICY, SERVICE_NAME } from '@/constants/terms'
const route = useRoute()
const router = useRouter()
const isPrivacy = computed(() => route.meta.doc === 'privacy')
const title = computed(() => (isPrivacy.value ? '개인정보처리방침' : '이용약관'))
const body = computed(() => (isPrivacy.value ? PRIVACY_POLICY : TERMS_OF_SERVICE))
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/')
}
</script>
<template>
<main class="legal">
<header class="legal-head">
<div class="brand">{{ SERVICE_NAME }}</div>
<h1>{{ title }}</h1>
</header>
<pre class="legal-body">{{ body }}</pre>
<div class="legal-foot">
<RouterLink :to="isPrivacy ? '/terms' : '/privacy'" class="legal-link">
{{ isPrivacy ? '이용약관' : '개인정보처리방침' }} 보기
</RouterLink>
<button type="button" class="legal-back" @click="goBack">돌아가기</button>
</div>
</main>
</template>
<style scoped>
.legal {
max-width: 760px;
margin: 0 auto;
padding: 1.5rem;
}
.legal-head {
text-align: center;
margin-bottom: 1.25rem;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.legal-head h1 {
font-size: 1.4rem;
margin-top: 0.3rem;
}
.legal-body {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.9rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.2rem 1.3rem;
margin: 0;
}
.legal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1.25rem;
}
.legal-link {
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
font-size: 0.9rem;
}
.legal-back {
padding: 0.5rem 1.3rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { billingApi } from '@/api/billingApi'
import { billing } from '@/native/billing'
import { BILLING_ENABLED } from '@/config/features'
const router = useRouter()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium)
// 결제는 Android 앱에서만 — PC(웹/데스크톱)는 앱 다운로드로 유도
const isNative = billing.isNative()
const APP_DOWNLOAD_URL = 'https://app.sblog.kr' // TODO: 실제 앱(APK/Play 스토어) 다운로드 주소로 교체
const products = ref([])
const purchasing = ref('')
const message = ref('')
const error = ref('')
// 만료일 표시
const expiresAt = computed(() => auth.user?.planExpiresAt)
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`
}
onMounted(async () => {
if (!isNative || !BILLING_ENABLED) return // PC·결제 비활성화 시 상품 목록 불필요
try {
products.value = await billingApi.products()
} catch {
products.value = []
}
})
async function buy(product) {
if (purchasing.value) return
purchasing.value = product.id
message.value = ''
error.value = ''
try {
const receipt = await billing.purchase(product.id)
await billingApi.verifyGoogle(receipt)
await auth.refreshProfile() // plan/만료일 갱신
message.value = '결제가 완료되어 프리미엄이 적용되었습니다. 감사합니다!'
} catch (e) {
error.value = e.response?.data?.message || '결제에 실패했습니다. 다시 시도해 주세요.'
} finally {
purchasing.value = ''
}
}
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
const freeFeatures = [
'가계부 내역 기록 · 조회',
'계좌 관리 (개수 제한 없음)',
'자주 쓰는 내역',
'월별 수입/지출 요약',
'기본 분류',
'게시판 · 다크 모드',
]
const premiumFeatures = [
'전체 통계 (분류별·추이·자산)',
'예산 설정',
'고정 지출 자동 반영',
'영수증 OCR 인식',
'카드 알림 자동 인식',
'투자 · 자산 관리',
'태그 · 소분류 무제한',
'데이터 백업 / 복구 (엑셀)',
'광고 제거',
]
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/account/entries')
}
</script>
<template>
<div class="upgrade">
<header class="up-head">
<div class="crown">👑</div>
<h1 v-if="isPremium">유료 멤버십 이용 </h1>
<h1 v-else>유료 멤버십으로 많은 기능을</h1>
<p v-if="isPremium" class="sub">모든 프리미엄 기능을 사용할 있습니다. 감사합니다!</p>
<p v-else class="sub">통계·예산·자동화·백업까지, 가계부를 200% 활용하세요.</p>
</header>
<div class="plans">
<section class="plan">
<h2>무료</h2>
<ul>
<li v-for="f in freeFeatures" :key="f"><span class="dot ok"></span>{{ f }}</li>
</ul>
</section>
<section class="plan premium">
<h2>유료 <span class="badge">PREMIUM</span></h2>
<ul>
<li v-for="f in premiumFeatures" :key="f"><span class="dot pro"></span>{{ f }}</li>
</ul>
</section>
</div>
<div class="cta">
<!-- 이미 유료 회원 -->
<p v-if="isPremium" class="status-premium">
프리미엄 이용 <span v-if="expiresAt"> · {{ fmtDate(expiresAt) }}까지</span>
</p>
<!-- 결제 비활성화( 출시 무료 ): 구독 준비 안내 -->
<template v-else-if="!BILLING_ENABLED">
<p class="notice">
유료 멤버십 구독은 <b> 출시 예정</b>입니다. 📦<br />
지금은 모든 <b>무료 기능</b> 제한 없이 이용하실 있어요.
</p>
</template>
<!-- 미가입 + Android : 상품 구매 -->
<template v-else-if="isNative">
<div class="products">
<button
v-for="p in products"
:key="p.id"
type="button"
class="buy-btn"
:disabled="!!purchasing"
@click="buy(p)"
>
<span class="buy-label">{{ p.label }}</span>
<span class="buy-price">{{ p.priceKrw.toLocaleString('ko-KR') }}<span class="buy-per"> / {{ p.months }}개월</span></span>
<span v-if="purchasing === p.id" class="buy-ing">처리 </span>
</button>
</div>
<p v-if="!products.length" class="notice">상품을 불러오지 못했습니다.</p>
</template>
<!-- 미가입 + PC(/데스크톱): 다운로드 유도 -->
<template v-else>
<p class="notice">
📱 구독 결제는 <b>모바일 </b>에서 진행됩니다.<br />
앱을 설치한 로그인하여 프리미엄을 구독해 주세요.
</p>
<a class="primary-link" :href="APP_DOWNLOAD_URL" target="_blank" rel="noopener"> 다운로드</a>
</template>
<p v-if="message" class="msg-ok">{{ message }}</p>
<p v-if="error" class="msg-err">{{ error }}</p>
<button type="button" class="ghost" @click="goBack">돌아가기</button>
</div>
</div>
</template>
<style scoped>
.upgrade {
max-width: 760px;
margin: 0 auto;
}
.up-head {
text-align: center;
margin-bottom: 1.5rem;
}
.crown {
font-size: 2.4rem;
}
.up-head h1 {
font-size: 1.4rem;
margin: 0.4rem 0 0.3rem;
color: var(--color-heading);
}
.sub {
color: var(--color-text);
opacity: 0.8;
font-size: 0.95rem;
}
.plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.plan {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.1rem 1.2rem;
background: var(--color-background-soft);
}
.plan.premium {
border-color: hsla(40, 90%, 55%, 0.7);
background: hsla(40, 90%, 55%, 0.06);
}
.plan h2 {
font-size: 1.05rem;
margin: 0 0 0.8rem;
color: var(--color-heading);
}
.badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: hsla(40, 90%, 50%, 0.9);
color: #3a2a00;
vertical-align: middle;
}
.plan ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.plan li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.dot {
flex: none;
width: 18px;
text-align: center;
}
.dot.ok {
color: hsla(160, 100%, 37%, 1);
}
.dot.pro {
color: hsla(40, 90%, 50%, 1);
}
.cta {
margin-top: 1.6rem;
text-align: center;
}
.notice {
font-size: 0.88rem;
color: var(--color-text);
opacity: 0.85;
margin-bottom: 0.9rem;
line-height: 1.6;
}
.status-premium {
font-size: 1rem;
font-weight: 700;
color: #b8860b;
margin-bottom: 1rem;
}
.products {
display: flex;
flex-direction: column;
gap: 0.6rem;
max-width: 360px;
margin: 0 auto 0.9rem;
}
.buy-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 1.1rem;
border: 1px solid hsla(40, 90%, 50%, 0.8);
border-radius: 10px;
background: hsla(40, 90%, 55%, 0.08);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.buy-btn:disabled {
opacity: 0.6;
cursor: default;
}
.buy-label {
font-weight: 700;
}
.buy-price {
margin-left: auto;
font-weight: 700;
color: #b8860b;
}
.buy-per {
font-weight: 400;
font-size: 0.82rem;
opacity: 0.7;
}
.buy-ing {
font-size: 0.82rem;
opacity: 0.7;
}
.primary-link {
display: inline-block;
padding: 0.6rem 1.6rem;
border-radius: 8px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 700;
margin-bottom: 0.9rem;
}
.msg-ok {
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
margin-bottom: 0.9rem;
}
.msg-err {
color: #c0392b;
margin-bottom: 0.9rem;
}
.ghost {
padding: 0.55rem 1.4rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
@media (max-width: 560px) {
.plans {
grid-template-columns: 1fr;
}
}
</style>
+32 -3
View File
@@ -79,6 +79,17 @@ async function changeRole(m, role) {
} }
} }
async function changePlan(m, plan) {
if (plan === m.plan) return
try {
const updated = await adminApi.updateMemberPlan(m.id, plan)
Object.assign(m, updated)
} catch (e) {
alert(e.response?.data?.message || '멤버십 변경 실패')
await load()
}
}
async function changeStatus(m, status) { async function changeStatus(m, status) {
if (status === m.status) return if (status === m.status) return
try { try {
@@ -108,14 +119,13 @@ onMounted(() => {
<template> <template>
<main class="members"> <main class="members">
<header class="head"> <header class="head" style="justify-content: flex-end">
<h1>회원 관리</h1>
<div class="head-actions"> <div class="head-actions">
<input v-model="keyword" type="search" class="search" placeholder="아이디·이름·이메일 검색" /> <input v-model="keyword" type="search" class="search" placeholder="아이디·이름·이메일 검색" />
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" /> <IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
</div> </div>
</header> </header>
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p> <p class="hint">가입한 회원의 역할·멤버십·상태를 관리합니다. (관리자 전용)</p>
<!-- 회원가입 허용 토글 --> <!-- 회원가입 허용 토글 -->
<div class="signup-toggle" :class="{ off: !signupEnabled }"> <div class="signup-toggle" :class="{ off: !signupEnabled }">
@@ -140,6 +150,7 @@ onMounted(() => {
<th>아이디 / 이름</th> <th>아이디 / 이름</th>
<th class="c-email">이메일</th> <th class="c-email">이메일</th>
<th class="c-role">역할</th> <th class="c-role">역할</th>
<th class="c-plan">멤버십</th>
<th class="c-status">상태</th> <th class="c-status">상태</th>
<th class="c-date">가입일</th> <th class="c-date">가입일</th>
<th class="c-act"></th> <th class="c-act"></th>
@@ -159,6 +170,16 @@ onMounted(() => {
<option value="ADMIN">ADMIN</option> <option value="ADMIN">ADMIN</option>
</select> </select>
</td> </td>
<td class="c-plan">
<select
:value="m.plan || 'FREE'"
class="plan-sel" :class="(m.plan || 'FREE').toLowerCase()"
@change="changePlan(m, $event.target.value)"
>
<option value="FREE">무료</option>
<option value="PREMIUM">유료</option>
</select>
</td>
<td class="c-status"> <td class="c-status">
<select <select
:value="m.status" :disabled="m.id === myId" :value="m.status" :disabled="m.id === myId"
@@ -290,6 +311,7 @@ h1 {
width: 48px; width: 48px;
} }
.c-role, .c-role,
.c-plan,
.c-status { .c-status {
width: 90px; width: 90px;
} }
@@ -348,6 +370,13 @@ h1 {
.status-sel.withdrawn { .status-sel.withdrawn {
color: #c0392b; color: #c0392b;
} }
.plan-sel.premium {
color: #b8860b;
font-weight: 600;
}
.plan-sel.free {
color: var(--color-text);
}
.msg { .msg {
margin: 1rem 0; margin: 1rem 0;
} }
+23 -35
View File
@@ -114,12 +114,32 @@ function hideTip() {
const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60) const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60)
const PIE_C = 2 * Math.PI * PIE_R const PIE_C = 2 * Math.PI * PIE_R
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d'] const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0)) // 분류별 집계를 대분류 기준으로 묶는다 (소분류까지 펼치면 항목이 너무 많아 스크롤이 길어짐).
// 소분류는 부모(대분류)명으로 합산, 매핑이 없으면 자기 자신을 대분류로 본다.
const catByMajor = computed(() => {
const list = categoryList.value.filter((c) => c.type === catType.value)
const byId = {}
list.forEach((c) => (byId[c.id] = c))
const toMajor = (name) => {
const c = list.find((x) => x.name === name)
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
return name
}
const map = {}
for (const d of catData.value) {
const major = toMajor(d.category)
map[major] = (map[major] || 0) + d.total
}
return Object.entries(map)
.map(([category, total]) => ({ category, total }))
.sort((a, b) => b.total - a.total)
})
const catTotal = computed(() => catByMajor.value.reduce((s, d) => s + d.total, 0))
const pieSegments = computed(() => { const pieSegments = computed(() => {
const total = catTotal.value const total = catTotal.value
if (total <= 0) return [] if (total <= 0) return []
let acc = 0 let acc = 0
return catData.value.map((d, i) => { return catByMajor.value.map((d, i) => {
const len = (d.total / total) * PIE_C const len = (d.total / total) * PIE_C
const seg = { const seg = {
category: d.category, category: d.category,
@@ -196,26 +216,6 @@ async function loadCategoryList() {
categoryList.value = [] categoryList.value = []
} }
} }
// 소분류명 → 대분류명 매핑(현재 구분). 소분류가 아니면 자기 자신.
const majorRollup = computed(() => {
const list = categoryList.value.filter((c) => c.type === catType.value)
const byId = {}
list.forEach((c) => (byId[c.id] = c))
const toMajor = (name) => {
const c = list.find((x) => x.name === name)
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
return name
}
const map = {}
for (const d of catData.value) {
const major = toMajor(d.category)
map[major] = (map[major] || 0) + d.total
}
const rows = Object.entries(map).map(([category, total]) => ({ category, total })).sort((a, b) => b.total - a.total)
// 실제로 묶인 게 있을 때만(소분류 존재) 노출
const hasSub = list.some((c) => c.parentId != null) && rows.length < catData.value.length
return hasSub ? rows : []
})
function setCatType(t) { function setCatType(t) {
catType.value = t catType.value = t
loadCat() loadCat()
@@ -324,9 +324,6 @@ onMounted(async () => {
<template> <template>
<section class="dash"> <section class="dash">
<header class="head">
<h1>통계</h1>
</header>
<div class="month-nav"> <div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" /> <IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
@@ -422,16 +419,7 @@ onMounted(async () => {
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="majorRollup.length" class="major-rollup"> <p v-if="!pieSegments.length" class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
<div class="mr-head">대분류 합계</div>
<ul class="mr-list">
<li v-for="m in majorRollup" :key="m.category">
<span class="mr-cat">{{ m.category }}</span>
<span class="mr-amt">{{ won(m.total) }}</span>
</li>
</ul>
</div>
<p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
</div> </div>
<!-- 기간별 예산 대비 지출 막대 차트 --> <!-- 기간별 예산 대비 지출 막대 차트 -->
-6
View File
@@ -1,12 +1,10 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref, nextTick } from 'vue' import { onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const tags = ref([]) const tags = ref([])
@@ -100,10 +98,6 @@ onBeforeUnmount(() => sortable?.destroy())
<template> <template>
<section class="tag-admin"> <section class="tag-admin">
<header class="head">
<h1>가계부 태그 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p> <p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
<form class="new-tag" @submit.prevent="addTag"> <form class="new-tag" @submit.prevent="addTag">
+481 -121
View File
@@ -1,14 +1,20 @@
<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 { 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'
import { CARD_NOTIF_ENABLED } from '@/config/features'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발) // @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
const dialog = useDialog() const dialog = useDialog()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
const now = new Date() const now = new Date()
const year = ref(now.getFullYear()) const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1) const month = ref(now.getMonth() + 1)
@@ -21,7 +27,7 @@ const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지 const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
// 카드 결제 알림 자동인식 권한 (네이티브 전용) // 카드 결제 알림 자동인식 권한 (네이티브 전용)
const notifNative = cardNotif.isNative() const notifNative = CARD_NOTIF_ENABLED && cardNotif.isNative()
const notifEnabled = ref(true) // 미허용일 때만 배너 노출 const notifEnabled = ref(true) // 미허용일 때만 배너 노출
async function checkNotifPermission() { async function checkNotifPermission() {
if (notifNative) notifEnabled.value = await cardNotif.isEnabled() if (notifNative) notifEnabled.value = await cardNotif.isEnabled()
@@ -78,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(() => {
@@ -131,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,
@@ -204,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 : ''
@@ -231,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
@@ -265,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() {
@@ -329,6 +444,11 @@ function toggleTag(id) {
} }
async function loadTagOptions() { async function loadTagOptions() {
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
if (!isPremium.value) {
tagOptions.value = []
return
}
try { try {
tagOptions.value = await accountApi.tags() tagOptions.value = await accountApi.tags()
} catch { } catch {
@@ -440,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()
@@ -469,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 = {}
@@ -547,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,
@@ -557,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) {
@@ -569,6 +697,8 @@ async function submit() {
await accountApi.create(payload) await accountApi.create(payload)
} }
formOpen.value = false formOpen.value = false
// 오늘 거래를 기록했으면 오늘자 가계부 리마인더 취소
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
await load() await load()
await loadPendingCount() await loadPendingCount()
} catch (e) { } catch (e) {
@@ -654,7 +784,6 @@ async function doRegisterRecurring() {
// ===== 자주 쓰는 내역(빠른 등록 템플릿) ===== // ===== 자주 쓰는 내역(빠른 등록 템플릿) =====
const quickEntries = ref([]) const quickEntries = ref([])
const quickEditMode = ref(false) const quickEditMode = ref(false)
const quickPickerOpen = ref(false) // 불러오기 모달 피커
async function loadQuick() { async function loadQuick() {
try { try {
quickEntries.value = await accountApi.quickEntries() quickEntries.value = await accountApi.quickEntries()
@@ -668,6 +797,7 @@ function quickLabel(q) {
// 칩 탭 → 오늘 날짜로 즉시 등록 // 칩 탭 → 오늘 날짜로 즉시 등록
async function useQuick(q) { async function useQuick(q) {
if (quickEditMode.value) return if (quickEditMode.value) return
// 1) 등록 — 이 단계 실패만 '등록 실패'로 처리
try { try {
await accountApi.create({ await accountApi.create({
entryDate: todayStr(), entryDate: todayStr(),
@@ -680,11 +810,17 @@ async function useQuick(q) {
installmentMonths: null, installmentMonths: null,
tagIds: [], tagIds: [],
}) })
await load()
await loadPendingCount()
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
} catch (e) { } catch (e) {
flash(e.response?.data?.message || '빠른 등록에 실패했습니다.') flash(e.response?.data?.message || '빠른 등록에 실패했습니다.')
return
}
// 2) 등록 성공 안내 + 목록 갱신(갱신 실패는 등록 성공을 가리지 않게 분리)
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
try {
await load()
await loadPendingCount()
} catch {
/* 목록 갱신 실패는 무시 — 다음 진입/새로고침 시 반영 */
} }
} }
async function removeQuick(q) { async function removeQuick(q) {
@@ -695,23 +831,6 @@ async function removeQuick(q) {
flash(e.response?.data?.message || '삭제에 실패했습니다.') flash(e.response?.data?.message || '삭제에 실패했습니다.')
} }
} }
// 신규 추가 시: 자주 쓰는 내역을 폼에 불러오기(채움, 저장은 사용자가)
function applyQuick(q) {
if (!q) return
form.type = q.type
form.category = q.category || ''
form.amount = q.amount
form.memo = q.memo || ''
if (q.walletId) {
form.walletId = q.walletId
form.walletKind = walletKindOf(q.walletId) || form.walletKind
}
syncCategoryMajor()
}
function pickQuick(q) {
applyQuick(q)
quickPickerOpen.value = false
}
// 현재 폼 값을 자주 쓰는 내역으로 저장 // 현재 폼 값을 자주 쓰는 내역으로 저장
async function saveAsQuick() { async function saveAsQuick() {
if (form.type !== 'INCOME' && form.type !== 'EXPENSE') return if (form.type !== 'INCOME' && form.type !== 'EXPENSE') return
@@ -806,10 +925,13 @@ async function analyzePasted() {
onMounted(async () => { onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이) // 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try { // 고정지출은 유료 전용 — 무료 회원은 호출 자체를 건너뛴다(403/업그레이드 리다이렉트 방지)
await accountApi.runRecurrings() if (isPremium.value) {
} catch { try {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다 await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
}
} }
load() load()
loadTagOptions() loadTagOptions()
@@ -823,14 +945,14 @@ onMounted(async () => {
<template> <template>
<section class="account"> <section class="account">
<header class="account-head"> <header v-if="pendingCount" class="account-head">
<h1>가계부<span v-if="pendingCount" class="pending-count">확인 필요 {{ pendingCount }}</span></h1> <span class="pending-count">확인 필요 {{ pendingCount }}</span>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</header> </header>
<Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition> <Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition>
<!-- 자주 쓰는 내역(빠른 등록) 오늘 날짜로 즉시 등록 --> <!-- 자주 쓰는 내역(빠른 등록) 오늘 날짜로 즉시 등록 -->
<div v-if="quickEntries.length" class="quick-bar"> <div v-if="quickEntries.length" class="quick-bar">
<span class="quick-bar-label"> 자주 쓰는 내역</span>
<button <button
v-for="q in quickEntries" :key="q.id" type="button" class="quick-chip" v-for="q in quickEntries" :key="q.id" type="button" class="quick-chip"
:title="quickEditMode ? '' : '오늘 날짜로 바로 등록'" @click="useQuick(q)" :title="quickEditMode ? '' : '오늘 날짜로 바로 등록'" @click="useQuick(q)"
@@ -843,19 +965,23 @@ onMounted(async () => {
</div> </div>
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) --> <!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) -->
<div v-if="notifNative && !notifEnabled" class="notif-banner"> <div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span> <span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span>
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button> <button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
</div> </div>
<div class="month-nav"> <div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" /> <IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span> <span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" /> <IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<IconBtn <div class="mn-actions">
icon="filter" title="검색·필터" class="filter-toggle" <IconBtn
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen" icon="filter" title="검색·필터" class="filter-toggle"
/> :variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
/>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</div>
</div> </div>
<div v-if="filterOpen" class="filter-bar"> <div v-if="filterOpen" class="filter-bar">
@@ -937,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>
@@ -946,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>
@@ -966,6 +1099,7 @@ onMounted(async () => {
/> />
<div class="rb-actions"> <div class="rb-actions">
<button <button
v-if="isPremium"
type="button" class="receipt-btn" type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt" :disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button> >📷 영수증 스캔</button>
@@ -986,13 +1120,6 @@ onMounted(async () => {
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div> <div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div> </div>
<!-- 신규 추가 : 자주 쓰는 내역 불러오기(모달 피커) -->
<button
v-if="!editId && quickEntries.length"
type="button" class="to-recurring to-quick"
:disabled="submitting" @click="quickPickerOpen = true"
> 자주 쓰는 내역 불러오기</button>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label> <label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분 <label>구분
<select v-model="form.type" :disabled="submitting" @change="onTypeChange"> <select v-model="form.type" :disabled="submitting" @change="onTypeChange">
@@ -1014,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>
@@ -1042,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>
@@ -1054,41 +1181,100 @@ 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" class="tag-field"> <div v-if="!isRepayment && isPremium" class="tag-field">
<span class="tag-label">태그</span> <span class="tag-label">태그</span>
<div class="tag-badges"> <div class="tag-badges">
<button <button
@@ -1107,7 +1293,7 @@ onMounted(async () => {
<p v-if="formInfo" class="msg ok">{{ formInfo }}</p> <p v-if="formInfo" class="msg ok">{{ formInfo }}</p>
<button <button
v-if="editId && form.type !== 'REPAYMENT'" v-if="isPremium && editId && form.type !== 'REPAYMENT'"
type="button" type="button"
class="to-recurring" class="to-recurring"
:disabled="submitting" :disabled="submitting"
@@ -1177,29 +1363,6 @@ onMounted(async () => {
</Teleport> </Teleport>
<!-- 자주 쓰는 내역 불러오기 (모달 피커) --> <!-- 자주 쓰는 내역 불러오기 (모달 피커) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="quickPickerOpen" class="modal-backdrop quick-picker-backdrop" @click.self="quickPickerOpen = false">
<div class="modal quick-picker" role="dialog" aria-modal="true">
<button class="close" type="button" @click="quickPickerOpen = false">×</button>
<h2>자주 쓰는 내역 불러오기</h2>
<ul v-if="quickEntries.length" class="qp-list">
<li v-for="q in quickEntries" :key="q.id">
<button type="button" class="qp-item" @click="pickQuick(q)">
<span class="qp-label">{{ quickLabel(q) }}</span>
<span class="qp-meta">
<span v-if="q.category" class="qp-cat">{{ q.category }}</span>
<span class="qp-amt" :class="q.type === 'INCOME' ? 'income' : 'expense'">{{ q.type === 'INCOME' ? '+' : '-' }}{{ won(q.amount) }}</span>
</span>
</button>
</li>
</ul>
<p v-else class="msg">저장된 자주 쓰는 내역이 없습니다.</p>
</div>
</div>
</Transition>
</Teleport>
<!-- 문자/푸시 붙여넣기 (모달) --> <!-- 문자/푸시 붙여넣기 (모달) -->
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
@@ -1265,6 +1428,16 @@ button.primary {
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* 좌측 spacer + 우측 액션을 같은 flex:1 로 둬서 가운데 네비를 정확히 중앙 유지 */
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period { .period {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -1526,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;
} }
@@ -1567,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%;
@@ -1754,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;
@@ -1775,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;
@@ -1823,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;
@@ -1839,6 +2193,12 @@ button.primary {
margin: 0.4rem 0 0.2rem; margin: 0.4rem 0 0.2rem;
align-items: center; align-items: center;
} }
.quick-bar-label {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.65;
margin-right: 0.2rem;
}
.quick-chip { .quick-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+96 -8
View File
@@ -1,12 +1,10 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue' import { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const TABS = [ const TABS = [
@@ -35,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)
@@ -198,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) {
@@ -231,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
@@ -247,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
@@ -260,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(),
@@ -273,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)
@@ -299,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()
@@ -310,10 +333,6 @@ onBeforeUnmount(() => sortable?.destroy())
<template> <template>
<section class="wallet"> <section class="wallet">
<header class="head">
<h1>계좌 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<!-- 순자산 요약 --> <!-- 순자산 요약 -->
<div class="networth"> <div class="networth">
@@ -368,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">
@@ -427,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">
@@ -459,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">
@@ -801,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;
} }
+81 -12
View File
@@ -1,11 +1,9 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const now = new Date() const now = new Date()
@@ -129,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) {
@@ -156,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, {
@@ -215,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) {
@@ -244,18 +282,20 @@ onMounted(() => {
<template> <template>
<section class="budget"> <section class="budget">
<header class="head">
<h1>예산 설정</h1>
<div class="head-actions">
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
</div>
</header>
<div class="month-nav"> <div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" /> <IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span> <span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" /> <IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<div class="mn-actions">
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
</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> </div>
<!-- 예상 수입 vs 예산 --> <!-- 예상 수입 vs 예산 -->
@@ -421,6 +461,15 @@ button.primary {
gap: 1rem; gap: 1rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period { .period {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -428,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;
+90 -29
View File
@@ -1,11 +1,9 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const categories = ref([]) const categories = ref([])
const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상 const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상
@@ -64,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, {
@@ -184,18 +189,19 @@ onBeforeUnmount(destroySortables)
<template> <template>
<section class="cat"> <section class="cat">
<header class="head">
<h1>분류 관리</h1>
<div class="head-actions">
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</div>
</header>
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b> 묶을 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p> <p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b> 묶을 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
<div class="tabs"> <div class="tabs-row">
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button> <div class="tabs">
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button> <button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
</div>
<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">
@@ -208,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>
@@ -217,8 +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>
<span class="major-badge"></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)" />
@@ -227,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)" />
@@ -237,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">
@@ -300,6 +311,30 @@ button.danger {
border-color: #c0392b; border-color: #c0392b;
color: #c0392b; color: #c0392b;
} }
.tabs-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
}
.tabs-row .tabs {
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;
gap: 0.25rem; gap: 0.25rem;
@@ -312,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);
@@ -347,15 +383,6 @@ button.danger {
background: var(--color-background-soft); background: var(--color-background-soft);
border-bottom: 0 !important; border-bottom: 0 !important;
} }
.major-badge {
flex: 0 0 auto;
font-size: 0.62rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
padding: 0.05rem 0.25rem;
}
.sub-list { .sub-list {
list-style: none; list-style: none;
padding: 0 0 0 0.4rem; padding: 0 0 0 0.4rem;
@@ -417,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;
@@ -427,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;
+10 -10
View File
@@ -1,11 +1,9 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const recurrings = ref([]) const recurrings = ref([])
@@ -240,15 +238,11 @@ onMounted(load)
<template> <template>
<section class="recur"> <section class="recur">
<header class="head">
<h1>고정 지출</h1>
<div class="head-actions">
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
</div>
</header>
<p class="hint">등록한 주기에 맞춰 가계부 진입 자동으로 <b>확인 필요</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 <b>확인</b> 눌러 확정하세요.</p> <p class="hint">등록한 주기에 맞춰 가계부 진입 자동으로 <b>확인 필요</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 <b>확인</b> 눌러 확정하세요.</p>
<div class="recur-actions">
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
</div>
<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>
@@ -416,6 +410,12 @@ h1 {
opacity: 0.7; opacity: 0.7;
margin: 0.5rem 0 1rem; margin: 0.5rem 0 1rem;
} }
.recur-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
button { button {
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
-18
View File
@@ -1,12 +1,10 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { adminApi } from '@/api/adminApi' import { adminApi } from '@/api/adminApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const categories = ref([]) const categories = ref([])
@@ -150,12 +148,6 @@ onBeforeUnmount(destroySortables)
<template> <template>
<section class="cat"> <section class="cat">
<header class="head">
<h1>기본 분류 관리</h1>
<div class="head-actions">
<IconBtn icon="back" title="뒤로" @click="router.back()" />
</div>
</header>
<p class="hint"> <p class="hint">
전체 사용자 공용 <b>기본 분류</b> 템플릿입니다. 사용자가 분류 관리에서 <b>기본 분류 불러오기</b> 자기 분류에 복사해 씁니다. 전체 사용자 공용 <b>기본 분류</b> 템플릿입니다. 사용자가 분류 관리에서 <b>기본 분류 불러오기</b> 자기 분류에 복사해 씁니다.
<b>대분류 아래 소분류</b> 묶을 있습니다(2단계). <b>대분류 아래 소분류</b> 묶을 있습니다(2단계).
@@ -186,7 +178,6 @@ onBeforeUnmount(destroySortables)
<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 class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<span class="major-badge"></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)" />
@@ -289,15 +280,6 @@ input {
background: var(--color-background-soft); background: var(--color-background-soft);
border-bottom: 0 !important; border-bottom: 0 !important;
} }
.major-badge {
flex: 0 0 auto;
font-size: 0.62rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
padding: 0.05rem 0.25rem;
}
.sub-list { .sub-list {
list-style: none; list-style: none;
padding: 0 0 0 0.4rem; padding: 0 0 0 0.4rem;
+52 -45
View File
@@ -2,6 +2,7 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/adminApi' import { adminApi } from '@/api/adminApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { BOARDS } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const dialog = useDialog() const dialog = useDialog()
@@ -11,18 +12,17 @@ const error = ref(null)
const newCategoryName = ref('') const newCategoryName = ref('')
const newTag = reactive({}) // { [categoryId]: '입력값' } const newTag = reactive({}) // { [categoryId]: '입력값' }
const boardCategoryId = ref('') // 게시판에서 사용할 카테고리 ('' = 제한 없음)
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
categories.value = await adminApi.categories() const list = await adminApi.categories()
categories.value.forEach((c) => { list.forEach((c) => {
if (!Array.isArray(c.boards)) c.boards = []
if (newTag[c.id] === undefined) newTag[c.id] = '' if (newTag[c.id] === undefined) newTag[c.id] = ''
}) })
const setting = await adminApi.getBoardSetting() categories.value = list
boardCategoryId.value = setting.tagCategoryId ?? ''
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.' error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally { } finally {
@@ -30,21 +30,18 @@ async function load() {
} }
} }
async function saveBoardSetting() { // 카테고리의 게시판 매핑 토글
try { function toggleBoard(cat, key) {
const id = boardCategoryId.value === '' ? null : Number(boardCategoryId.value) const i = cat.boards.indexOf(key)
await adminApi.setBoardSetting(id) if (i >= 0) cat.boards.splice(i, 1)
alert('게시판 사용 카테고리를 저장했습니다.') else cat.boards.push(key)
} catch (e) {
alert(e.response?.data?.message || '저장 실패')
}
} }
async function addCategory() { async function addCategory() {
const name = newCategoryName.value.trim() const name = newCategoryName.value.trim()
if (!name) return if (!name) return
try { try {
await adminApi.createCategory({ name, sortOrder: categories.value.length }) await adminApi.createCategory({ name, sortOrder: categories.value.length, boards: [] })
newCategoryName.value = '' newCategoryName.value = ''
await load() await load()
} catch (e) { } catch (e) {
@@ -56,7 +53,8 @@ async function saveCategory(cat) {
const name = (cat.name || '').trim() const name = (cat.name || '').trim()
if (!name) return if (!name) return
try { try {
await adminApi.updateCategory(cat.id, { name, sortOrder: cat.sortOrder }) await adminApi.updateCategory(cat.id, { name, sortOrder: cat.sortOrder, boards: cat.boards })
alert('저장했습니다.')
await load() await load()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '카테고리 수정 실패') alert(e.response?.data?.message || '카테고리 수정 실패')
@@ -100,17 +98,7 @@ onMounted(load)
<template> <template>
<section class="tag-admin"> <section class="tag-admin">
<h1>태그 관리</h1> <p class="page-hint"> 태그 그룹을 사용할 게시판을 지정하세요. 선택한 게시판의 글쓰기에서만 그룹 태그가 노출됩니다. <b>게시판을 하나도 선택하지 않으면 어디에도 노출되지 않습니다.</b></p>
<div class="board-setting">
<label>게시판 사용 카테고리</label>
<select v-model="boardCategoryId">
<option value="">전체 허용 (제한 없음)</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<IconBtn icon="check" title="저장" variant="primary" @click="saveBoardSetting" />
<span class="hint">선택 글쓰기에서 해당 카테고리의 태그만 사용할 있습니다.</span>
</div>
<form class="new-category" @submit.prevent="addCategory"> <form class="new-category" @submit.prevent="addCategory">
<input v-model="newCategoryName" type="text" placeholder="새 카테고리 이름 (예: 게시판, 게시판2)" /> <input v-model="newCategoryName" type="text" placeholder="새 카테고리 이름 (예: 게시판, 게시판2)" />
@@ -123,10 +111,19 @@ onMounted(load)
<div v-for="cat in categories" :key="cat.id" class="category-card"> <div v-for="cat in categories" :key="cat.id" class="category-card">
<div class="cat-head"> <div class="cat-head">
<input v-model="cat.name" class="cat-name" @keyup.enter="saveCategory(cat)" /> <input v-model="cat.name" class="cat-name" @keyup.enter="saveCategory(cat)" />
<IconBtn icon="check" title="이름 저장" @click="saveCategory(cat)" /> <IconBtn icon="check" title="저장" @click="saveCategory(cat)" />
<IconBtn icon="trash" title="카테고리 삭제" variant="danger" @click="removeCategory(cat)" /> <IconBtn icon="trash" title="카테고리 삭제" variant="danger" @click="removeCategory(cat)" />
</div> </div>
<div class="board-map">
<span class="bm-label">사용 게시판</span>
<label v-for="b in BOARDS" :key="b.key" class="bm-check">
<input type="checkbox" :checked="cat.boards.includes(b.key)" @change="toggleBoard(cat, b.key)" />
{{ b.label }}
</label>
<span v-if="!cat.boards.length" class="bm-none">미노출</span>
</div>
<div class="tag-list"> <div class="tag-list">
<span v-for="t in cat.tags" :key="t.id" class="tag-chip"> <span v-for="t in cat.tags" :key="t.id" class="tag-chip">
{{ t.name }} {{ t.name }}
@@ -173,31 +170,41 @@ button.danger {
border-color: #c0392b; border-color: #c0392b;
color: #c0392b; color: #c0392b;
} }
.board-setting { .page-hint {
font-size: 0.85rem;
opacity: 0.7;
margin-bottom: 1rem;
line-height: 1.5;
}
.board-map {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.75rem; padding: 0.5rem 0 0.75rem;
margin-bottom: 1.25rem; margin-bottom: 0.5rem;
border: 1px solid var(--color-border); border-bottom: 1px dashed var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
} }
.board-setting label { .bm-label {
font-size: 0.82rem;
font-weight: 600; font-weight: 600;
opacity: 0.8;
} }
.board-setting select { .bm-check {
padding: 0.4rem 0.6rem; display: inline-flex;
border: 1px solid var(--color-border); align-items: center;
border-radius: 4px; gap: 0.3rem;
background: var(--color-background); font-size: 0.85rem;
color: var(--color-text); cursor: pointer;
} }
.board-setting .hint { .bm-check input {
font-size: 0.8rem; width: auto;
opacity: 0.65; cursor: pointer;
flex-basis: 100%; }
.bm-none {
font-size: 0.78rem;
color: #c0392b;
opacity: 0.8;
} }
.new-category { .new-category {
display: flex; display: flex;
+433 -7
View File
@@ -8,6 +8,7 @@ import { formatRelative } from '@/utils/datetime'
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue' import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue' import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_BOARD } from '@/constants/boards' import { DEFAULT_BOARD } from '@/constants/boards'
const route = useRoute() const route = useRoute()
@@ -38,6 +39,26 @@ function canModifyComment(c) {
return auth.user.id === c.authorId || isAdmin.value return auth.user.id === c.authorId || isAdmin.value
} }
// 본인 글/댓글 여부 (추천/비추천 차단용)
const isMyPost = computed(() => !!post.value && auth.user?.id === post.value.authorId)
function isMyComment(c) {
return auth.user?.id === c.authorId
}
// 본문 작성자가 단 댓글인지 (작성자 뱃지)
function isAuthorComment(c) {
return !!post.value && c.authorId === post.value.authorId
}
// 비추천 20개 이상 블라인드 + 펼치기 상태
const BLIND_DOWN = 20
function isBlinded(item) {
return (item?.downCount || 0) >= BLIND_DOWN
}
const postRevealed = ref(false)
const revealedComments = ref({})
function toggleReveal(c) {
revealedComments.value[c.id] = !revealedComments.value[c.id]
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -54,7 +75,7 @@ async function removePost() {
if (!(await dialog.confirm('이 게시글을 삭제하시겠습니까?', { title: '게시글 삭제', danger: true }))) return if (!(await dialog.confirm('이 게시글을 삭제하시겠습니까?', { title: '게시글 삭제', danger: true }))) return
try { try {
await boardApi.remove(postId) await boardApi.remove(postId)
router.replace(`/board/${category.value}`) router.replace({ path: `/board/${category.value}`, query: route.query })
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.') alert(e.response?.data?.message || '삭제에 실패했습니다.')
} }
@@ -105,6 +126,87 @@ async function removeComment(c) {
} }
} }
// ===== 신고 =====
async function reportPost() {
if (!(await dialog.confirm('이 게시글을 신고하시겠어요?\n신고가 누적되면 자동으로 블라인드 처리됩니다.', { title: '게시글 신고', danger: true }))) return
try {
await boardApi.reportPost(postId)
await dialog.alert('신고가 접수되었습니다.')
await load()
} catch (e) {
alert(e.response?.data?.message || '신고에 실패했습니다.')
}
}
async function reportComment(c) {
if (!(await dialog.confirm('이 댓글을 신고하시겠어요?\n신고가 누적되면 자동으로 블라인드 처리됩니다.', { title: '댓글 신고', danger: true }))) return
try {
await boardApi.reportComment(c.id)
await dialog.alert('신고가 접수되었습니다.')
await refreshComments()
} catch (e) {
alert(e.response?.data?.message || '신고에 실패했습니다.')
}
}
async function unblockComment(c) {
try {
await boardApi.unblockComment(c.id)
await refreshComments()
} catch (e) {
alert(e.response?.data?.message || '해제에 실패했습니다.')
}
}
// 댓글 목록만 새로고침 (다른 사람이 단 댓글·추천 반영)
const refreshingComments = ref(false)
async function refreshComments() {
if (refreshingComments.value) return
refreshingComments.value = true
try {
const fresh = await boardApi.get(postId)
post.value.comments = fresh.comments
} catch (e) {
alert(e.response?.data?.message || '댓글을 새로고침하지 못했습니다.')
} finally {
refreshingComments.value = false
}
}
// ===== 추천/비추천 =====
const voting = ref(false)
async function votePost(type) {
if (voting.value) return
voting.value = true
try {
const res = await boardApi.votePost(postId, type)
post.value.upCount = res.upCount
post.value.downCount = res.downCount
post.value.myVote = res.myVote
// 추천 변동 → 추천자 목록 갱신
post.value.recommenders = await boardApi.recommenders(postId)
} catch (e) {
alert(e.response?.data?.message || '처리에 실패했습니다.')
} finally {
voting.value = false
}
}
async function voteComment(c, type) {
try {
const res = await boardApi.voteComment(c.id, type)
c.upCount = res.upCount
c.downCount = res.downCount
c.myVote = res.myVote
} catch (e) {
alert(e.response?.data?.message || '처리에 실패했습니다.')
}
}
// ===== 추천자 목록 =====
const recommenders = computed(() => post.value?.recommenders || [])
const previewRecommenders = computed(() => recommenders.value.slice(0, 10))
const extraRecommenders = computed(() => Math.max(0, (post.value?.upCount || 0) - 10))
const showRecommenders = ref(false)
onMounted(load) onMounted(load)
</script> </script>
@@ -115,11 +217,18 @@ onMounted(load)
<article v-if="post"> <article v-if="post">
<header class="post-head"> <header class="post-head">
<h1>{{ post.title }}</h1> <h1><span v-if="post.notice" class="notice-badge">공지</span>{{ post.title }}</h1>
<div class="meta"> <div class="meta">
<span>{{ post.authorName }}</span> <UserAvatar
:name="post.authorName"
:google-picture="post.authorGooglePicture"
:profile-image="post.authorProfileImage"
:size="16"
/>
<span class="meta-author">{{ post.authorName }}</span>
<span>· {{ formatRelative(post.createdAt) }}</span> <span>· {{ formatRelative(post.createdAt) }}</span>
<span>· 조회 {{ post.viewCount }}</span> <span>· 조회 {{ post.viewCount }}</span>
<span>· 추천 {{ post.upCount || 0 }}</span>
</div> </div>
</header> </header>
@@ -134,16 +243,76 @@ onMounted(load)
🚫 관리자에 의해 열람이 제한된 게시글입니다. 🚫 관리자에 의해 열람이 제한된 게시글입니다.
</div> </div>
<!-- 비추천 많은 블라인드 (작성자·관리자는 정상 표시) -->
<div v-else-if="isBlinded(post) && !isMyPost && !isAdmin && !postRevealed" class="blinded">
🙈 비추천이 많아 블라인드 처리된 게시글입니다.
<button type="button" class="reveal-btn" @click="postRevealed = true">그래도 보기</button>
</div>
<MarkdownViewer v-else :content="post.content" class="content" /> <MarkdownViewer v-else :content="post.content" class="content" />
<!-- 추천 / 비추천 (본인 글은 비활성) -->
<div v-if="!restricted" class="vote-bar">
<button
type="button" class="vote-btn up" :class="{ active: post.myVote === 'UP' }"
:disabled="voting || isMyPost"
:title="isMyPost ? '본인 글은 추천할 수 없습니다' : ''"
@click="votePost('UP')"
>👍 추천 <b>{{ post.upCount || 0 }}</b></button>
<button
type="button" class="vote-btn down" :class="{ active: post.myVote === 'DOWN' }"
:disabled="voting || isMyPost"
:title="isMyPost ? '본인 글은 비추천할 수 없습니다' : ''"
@click="votePost('DOWN')"
>👎 비추천 <b>{{ post.downCount || 0 }}</b></button>
</div>
<!-- 추천자 (아바타 최대 10 + 초과 안내, 클릭 하단에 전체 목록 펼침) -->
<div v-if="!restricted && post.upCount" class="recommenders-wrap">
<button type="button" class="recommenders" @click="showRecommenders = !showRecommenders">
<span class="rec-avatars">
<UserAvatar
v-for="r in previewRecommenders"
:key="r.memberId"
:name="r.name"
:google-picture="r.googlePicture"
:profile-image="r.profileImage"
:size="16"
class="rec-avatar"
/>
</span>
<span class="rec-label">
<template v-if="extraRecommenders > 0">+{{ extraRecommenders }}명이 추천했습니다</template>
<template v-else>{{ post.upCount }}명이 추천했습니다</template>
</span>
<span class="rec-toggle">{{ showRecommenders ? '▲' : '▼' }}</span>
</button>
<ul v-if="showRecommenders" class="rec-inline-list">
<li v-for="r in recommenders" :key="r.memberId">
<UserAvatar
:name="r.name"
:google-picture="r.googlePicture"
:profile-image="r.profileImage"
:size="16"
/>
<span class="rec-name">{{ r.name }}</span>
</li>
</ul>
</div>
<!-- 태그 (본문 아래) --> <!-- 태그 (본문 아래) -->
<div v-if="!restricted && post.tags?.length" class="tags"> <div v-if="!restricted && post.tags?.length" class="tags">
<span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span> <span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span>
</div> </div>
<div class="actions"> <div class="actions">
<IconBtn icon="back" title="목록" @click="router.push(`/board/${category}`)" /> <IconBtn icon="list" title="목록" @click="router.push({ path: `/board/${category}`, query: route.query })" />
<div class="right-actions"> <div class="right-actions">
<!-- 신고 (작성자·관리자 ) -->
<button
v-if="!isAdmin && !restricted && auth.user && post.authorId !== auth.user.id"
type="button" class="report-btn" @click="reportPost"
>🚩 신고</button>
<!-- 관리자 제한/해제 --> <!-- 관리자 제한/해제 -->
<button v-if="isAdmin && !post.blocked" type="button" class="warn" @click="blockPost">열람 제한</button> <button v-if="isAdmin && !post.blocked" type="button" class="warn" @click="blockPost">열람 제한</button>
<button v-if="isAdmin && post.blocked" type="button" class="warn" @click="unblockPost">제한 해제</button> <button v-if="isAdmin && post.blocked" type="button" class="warn" @click="unblockPost">제한 해제</button>
@@ -160,19 +329,68 @@ onMounted(load)
<h2>댓글 {{ post.comments.length }}</h2> <h2>댓글 {{ post.comments.length }}</h2>
<ul class="comment-list"> <ul class="comment-list">
<li v-for="c in post.comments" :key="c.id" class="comment"> <li v-for="c in post.comments" :key="c.id" class="comment" :class="{ 'hot-comment': c.hot }">
<div class="comment-head"> <div class="comment-head">
<UserAvatar
:name="c.authorName"
:google-picture="c.authorGooglePicture"
:profile-image="c.authorProfileImage"
:size="16"
/>
<strong>{{ c.authorName }}</strong> <strong>{{ c.authorName }}</strong>
<span v-if="isAuthorComment(c)" class="author-badge">작성자</span>
<span v-if="c.hot" class="hot-badge">🔥 인기</span>
<span v-if="c.blocked && isAdmin" class="report-badge">🚩 신고 블라인드</span>
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span> <span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
<span v-if="canModifyComment(c)" class="comment-del"> <span v-if="canModifyComment(c)" class="comment-del">
<IconBtn icon="trash" title="댓글 삭제" variant="danger" size="sm" @click="removeComment(c)" /> <IconBtn icon="trash" title="댓글 삭제" variant="danger" size="sm" @click="removeComment(c)" />
</span> </span>
</div> </div>
<MarkdownViewer :content="c.content" class="comment-body" /> <!-- 신고 누적 블라인드 (관리자만 본문 열람, 외엔 가림) -->
<div v-if="c.blocked && !isAdmin" class="blinded comment-blinded">
🚩 신고 누적으로 블라인드된 댓글입니다.
</div>
<!-- 비추천 많은 댓글 블라인드 (작성자·관리자는 정상) -->
<div v-else-if="isBlinded(c) && !isMyComment(c) && !isAdmin && !revealedComments[c.id]" class="blinded comment-blinded">
🙈 비추천이 많아 블라인드된 댓글입니다.
<button type="button" class="reveal-btn" @click="toggleReveal(c)">그래도 보기</button>
</div>
<template v-else>
<MarkdownViewer :content="c.content" class="comment-body" />
<div class="comment-votes">
<button
type="button" class="cvote up" :class="{ active: c.myVote === 'UP' }"
:disabled="isMyComment(c)"
:title="isMyComment(c) ? '본인 댓글은 추천할 수 없습니다' : ''"
@click="voteComment(c, 'UP')"
>👍 {{ c.upCount || 0 }}</button>
<button
type="button" class="cvote down" :class="{ active: c.myVote === 'DOWN' }"
:disabled="isMyComment(c)"
:title="isMyComment(c) ? '본인 댓글은 비추천할 수 없습니다' : ''"
@click="voteComment(c, 'DOWN')"
>👎 {{ c.downCount || 0 }}</button>
<button
v-if="!isAdmin && auth.user && c.authorId !== auth.user.id"
type="button" class="creport" @click="reportComment(c)"
>🚩 신고</button>
<button
v-if="isAdmin && c.blocked"
type="button" class="cunblock" @click="unblockComment(c)"
>블라인드 해제</button>
</div>
</template>
</li> </li>
</ul> </ul>
<p v-if="!post.comments.length" class="msg"> 댓글을 남겨보세요.</p> <p v-if="!post.comments.length" class="msg"> 댓글을 남겨보세요.</p>
<!-- 댓글 목록 최하단(입력폼 ) 새로고침 -->
<div class="comment-refresh">
<button type="button" class="refresh-btn" :disabled="refreshingComments" @click="refreshComments">
🔄 {{ refreshingComments ? '새로고침 ' : '댓글 새로고침' }}
</button>
</div>
<form v-if="!post.blocked" class="comment-form" @submit.prevent="addComment"> <form v-if="!post.blocked" class="comment-form" @submit.prevent="addComment">
<MarkdownEditor <MarkdownEditor
v-model="commentText" v-model="commentText"
@@ -208,11 +426,103 @@ h1 {
.meta { .meta {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text); color: var(--color-text);
opacity: 0.75; opacity: 0.85;
display: flex; display: flex;
align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.meta-author {
font-weight: 600;
}
/* 추천/비추천 */
.vote-bar {
display: flex;
justify-content: center;
gap: 0.75rem;
padding: 0.5rem 0 1rem;
}
.vote-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1.4rem;
border-radius: 999px;
font-size: 0.92rem;
}
.vote-btn b {
font-weight: 700;
}
.vote-btn.up.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.08);
}
.vote-btn.down.active {
border-color: #e67e22;
color: #e67e22;
background: rgba(230, 126, 34, 0.08);
}
/* 추천자 */
.recommenders-wrap {
margin-bottom: 0.75rem;
}
.recommenders {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
cursor: pointer;
text-align: left;
}
.recommenders:hover {
background: var(--color-background-mute);
}
.rec-avatars {
display: inline-flex;
}
.rec-avatar {
margin-left: -4px;
box-shadow: 0 0 0 2px var(--color-background-soft);
}
.rec-avatar:first-child {
margin-left: 0;
}
.rec-label {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.85;
}
.rec-toggle {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.6;
}
.rec-inline-list {
list-style: none;
margin: 0.4rem 0 0;
padding: 0.55rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.8rem;
}
.rec-inline-list li {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap; /* 이름이 잘리지 않도록 아바타+이름 단위로 줄바꿈 */
flex: none;
}
.rec-name {
font-size: 0.85rem;
}
.tags { .tags {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
@@ -268,6 +578,20 @@ button.warn {
border-color: #e67e22; border-color: #e67e22;
color: #e67e22; color: #e67e22;
} }
button.notice-btn {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.notice-badge {
margin-right: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.8rem;
font-weight: 700;
vertical-align: middle;
}
.actions { .actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -309,6 +633,108 @@ button.warn {
.comment-body { .comment-body {
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.author-badge {
padding: 0.05rem 0.35rem;
border-radius: 3px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.hot-comment {
background: rgba(231, 76, 60, 0.05);
border-radius: 6px;
}
.hot-badge {
padding: 0.05rem 0.35rem;
border-radius: 3px;
background: #e74c3c;
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.report-badge {
padding: 0.05rem 0.35rem;
border-radius: 3px;
border: 1px solid #c0392b;
color: #c0392b;
font-size: 0.68rem;
font-weight: 700;
}
.report-btn {
padding: 0.45rem 0.9rem;
border-radius: 4px;
color: #c0392b;
font-size: 0.85rem;
}
.creport {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
border-radius: 999px;
color: #c0392b;
opacity: 0.85;
}
.cunblock {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
border-radius: 999px;
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.comment-votes {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
/* 블라인드 */
.blinded {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 1.5rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text);
opacity: 0.85;
font-size: 0.92rem;
}
.comment-blinded {
padding: 0.7rem 0.8rem;
margin-top: 0.3rem;
font-size: 0.85rem;
}
.reveal-btn {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.82rem;
}
/* 댓글 새로고침 */
.comment-refresh {
display: flex;
justify-content: center;
margin: 0.5rem 0 0.25rem;
}
.refresh-btn {
padding: 0.35rem 1rem;
border-radius: 999px;
font-size: 0.85rem;
color: var(--color-text);
}
.cvote {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
border-radius: 999px;
}
.cvote.up.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.cvote.down.active {
border-color: #e67e22;
color: #e67e22;
}
.comment-form { .comment-form {
margin-top: 1rem; margin-top: 1rem;
} }
+123 -22
View File
@@ -1,20 +1,29 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi' import { boardApi } from '@/api/boardApi'
import { formatRelative } from '@/utils/datetime' import { formatRelative } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { boardLabel, DEFAULT_BOARD } from '@/constants/boards' import { DEFAULT_BOARD } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'ADMIN') const isAdmin = computed(() => auth.user?.role === 'ADMIN')
// 비추천 20개 이상이면 블라인드 (작성자·관리자에겐 정상 표시)
const BLIND_DOWN = 20
function isBlinded(p) {
return (p.downCount || 0) >= BLIND_DOWN
}
function showBlindMsg(p) {
return isBlinded(p) && !isAdmin.value && auth.user?.id !== p.authorId
}
// 현재 게시판(카테고리) // 현재 게시판(카테고리)
const category = computed(() => route.params.category || DEFAULT_BOARD) const category = computed(() => route.params.category || DEFAULT_BOARD)
const boardTitle = computed(() => boardLabel(category.value))
const posts = ref([]) const posts = ref([])
const tags = ref([]) const tags = ref([])
@@ -62,7 +71,7 @@ async function load() {
async function loadTags() { async function loadTags() {
try { try {
tags.value = await boardApi.tags() tags.value = await boardApi.tags(category.value)
} catch { } catch {
tags.value = [] tags.value = []
} }
@@ -123,25 +132,24 @@ watch(
{ immediate: true }, { immediate: true },
) )
onMounted(loadTags) // 게시판이 바뀌면 태그 필터도 그 게시판 기준으로 다시 로드
watch(category, loadTags, { immediate: true })
</script> </script>
<template> <template>
<section class="board"> <section class="board">
<header class="board-head"> <div class="list-top">
<h1>{{ boardTitle }}</h1> <div class="tag-filter">
<button
v-for="t in tags"
:key="t"
type="button"
class="tag-chip"
:class="{ active: activeTag === t }"
@click="filterByTag(t)"
>{{ t }}</button>
</div>
<IconBtn icon="edit" title="글쓰기" variant="primary" @click="router.push(`/board/${category}/write`)" /> <IconBtn icon="edit" title="글쓰기" variant="primary" @click="router.push(`/board/${category}/write`)" />
</header>
<div v-if="tags.length" class="tag-filter">
<button
v-for="t in tags"
:key="t"
type="button"
class="tag-chip"
:class="{ active: activeTag === t }"
@click="filterByTag(t)"
>{{ t }}</button>
</div> </div>
<!-- 검색 적용 표시 --> <!-- 검색 적용 표시 -->
@@ -159,23 +167,41 @@ onMounted(loadTags)
<th>제목</th> <th>제목</th>
<th class="col-author">작성자</th> <th class="col-author">작성자</th>
<th class="col-num">조회</th> <th class="col-num">조회</th>
<th class="col-recommend">추천</th>
<th class="col-date">작성일</th> <th class="col-date">작성일</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="p in posts" :key="p.id" @click="router.push(`/board/${category}/${p.id}`)"> <tr v-for="p in posts" :key="p.id" :class="{ 'notice-row': p.notice }" @click="router.push({ path: `/board/${category}/${p.id}`, query: route.query })">
<td> <td>
<span v-if="p.notice" class="notice-badge">공지</span>
<span v-if="p.hot" class="hot-badge">🔥 인기</span>
<template v-if="p.blocked && !isAdmin"> <template v-if="p.blocked && !isAdmin">
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span> <span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
</template> </template>
<template v-else-if="showBlindMsg(p)">
<span class="blocked-msg">🙈 비추천이 많아 블라인드된 게시글입니다.</span>
</template>
<template v-else> <template v-else>
<span class="title">{{ p.title }}</span> <span class="title">{{ p.title }}</span>
<span v-if="p.blocked" class="blocked-badge">제한</span> <span v-if="p.blocked" class="blocked-badge">제한</span>
<span v-if="isBlinded(p)" class="blind-badge">블라인드</span>
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span> <span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
</template> </template>
</td> </td>
<td class="col-author">{{ p.authorName }}</td> <td class="col-author">
<span class="author-cell">
<UserAvatar
:name="p.authorName"
:google-picture="p.authorGooglePicture"
:profile-image="p.authorProfileImage"
:size="16"
/>
<span class="author-name">{{ p.authorName }}</span>
</span>
</td>
<td class="col-num">{{ p.viewCount }}</td> <td class="col-num">{{ p.viewCount }}</td>
<td class="col-recommend">{{ p.upCount || 0 }}</td>
<td class="col-date">{{ formatRelative(p.createdAt) }}</td> <td class="col-date">{{ formatRelative(p.createdAt) }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -247,6 +273,20 @@ button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.list-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.list-top .tag-filter {
flex: 1;
margin-bottom: 0;
}
.list-top > .icon-btn {
flex: none;
}
.tag-filter { .tag-filter {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -294,8 +334,23 @@ button:disabled {
width: 60px; width: 60px;
text-align: center; text-align: center;
} }
.col-recommend {
width: 56px;
text-align: center;
}
.col-author { .col-author {
width: 100px; width: 130px;
}
.author-cell {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.author-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.col-date { .col-date {
width: 110px; width: 110px;
@@ -318,6 +373,41 @@ button:disabled {
color: #c0392b; color: #c0392b;
font-size: 0.72rem; font-size: 0.72rem;
} }
.notice-badge {
margin-right: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 3px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
}
.notice-row {
background: hsla(160, 100%, 37%, 0.06);
}
.notice-row .title {
font-weight: 600;
}
.hot-badge {
margin-right: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 3px;
background: #e74c3c;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
}
.blind-badge {
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
color: var(--color-text);
opacity: 0.6;
font-size: 0.72rem;
}
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */ /* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
.list-footer { .list-footer {
@@ -384,14 +474,19 @@ button:disabled {
gap: 0.5rem; gap: 0.5rem;
} }
.search-type { .search-type {
flex: none;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
background: var(--color-background-soft); background: var(--color-background-soft);
color: var(--color-text); color: var(--color-text);
} }
.search-form > .icon-btn {
flex: none;
}
.search-form input { .search-form input {
flex: 1; flex: 1;
min-width: 0; /* flex 자식이 내용 최소폭 아래로 줄어들 수 있게 — 검색 버튼이 모달 밖으로 밀리는 문제 방지 */
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
@@ -455,9 +550,15 @@ button:disabled {
.post-table .col-num::before { .post-table .col-num::before {
content: '· 조회 '; content: '· 조회 ';
} }
.post-table .col-date { .post-table .col-recommend {
order: 4; order: 4;
} }
.post-table .col-recommend::before {
content: '· 추천 ';
}
.post-table .col-date {
order: 5;
}
.post-table .col-date::before { .post-table .col-date::before {
content: '· '; content: '· ';
} }
+46 -8
View File
@@ -2,26 +2,38 @@
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi' import { boardApi } from '@/api/boardApi'
import { useAuthStore } from '@/stores/auth'
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue' import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import { DEFAULT_BOARD } from '@/constants/boards' import { DEFAULT_BOARD } from '@/constants/boards'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const category = route.params.category || DEFAULT_BOARD const category = route.params.category || DEFAULT_BOARD
const editId = route.params.id || null const editId = route.params.id || null
const isEdit = computed(() => !!editId) const isEdit = computed(() => !!editId)
// 공지: 관리자가 커뮤니티 게시판 글을 작성/수정할 때 체크박스로 설정
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
const canNotice = computed(() => isAdmin.value && category === 'community')
const title = ref('') const title = ref('')
const content = ref('') const content = ref('')
const notice = ref(false)
const tagGroups = ref([]) const tagGroups = ref([])
const selectedTagIds = ref([]) const selectedTagIds = ref([])
// 이 게시판 매핑엔 없지만 글이 이미 가진 태그(수정 시 보존용)
const extraTags = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// 카테고리 구분 없이 태그만 평탄화 // 이 게시판 그룹의 태그 + 글이 이미 가진 추가 태그(중복 제거)
const allTags = computed(() => tagGroups.value.flatMap((g) => g.tags)) const allTags = computed(() => tagGroups.value.flatMap((g) => g.tags))
const displayTags = computed(() => {
const seen = new Set(allTags.value.map((t) => t.id))
return [...allTags.value, ...extraTags.value.filter((t) => !seen.has(t.id))]
})
function toggleTag(id) { function toggleTag(id) {
const i = selectedTagIds.value.indexOf(id) const i = selectedTagIds.value.indexOf(id)
@@ -31,7 +43,8 @@ function toggleTag(id) {
async function loadTagGroups() { async function loadTagGroups() {
try { try {
tagGroups.value = await boardApi.tagGroups() // 새 글·수정 모두 이 게시판에 매핑된 그룹만 노출
tagGroups.value = await boardApi.tagGroups(category)
} catch { } catch {
tagGroups.value = [] tagGroups.value = []
} }
@@ -43,10 +56,18 @@ async function loadForEdit() {
const p = await boardApi.get(editId) const p = await boardApi.get(editId)
title.value = p.title title.value = p.title
content.value = p.content content.value = p.content
// 게시글의 태그(이름) → 현재 태그 목록에서 id 매핑 notice.value = !!p.notice
// 전체 그룹에서 이름→id 해석 (이 게시판에 안 보이는 기존 태그도 보존)
const all = await boardApi.tagGroups('')
const nameToId = {} const nameToId = {}
tagGroups.value.forEach((c) => c.tags.forEach((t) => (nameToId[t.name] = t.id))) const idToName = {}
all.forEach((c) => c.tags.forEach((t) => { nameToId[t.name] = t.id; idToName[t.id] = t.name }))
selectedTagIds.value = (p.tags || []).map((n) => nameToId[n]).filter(Boolean) selectedTagIds.value = (p.tags || []).map((n) => nameToId[n]).filter(Boolean)
// 이 게시판 그룹에 없는 선택 태그만 별도 배지로 노출(보존)
const shown = new Set(allTags.value.map((t) => t.id))
extraTags.value = selectedTagIds.value
.filter((id) => !shown.has(id))
.map((id) => ({ id, name: idToName[id] }))
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '게시글을 불러오지 못했습니다.' error.value = e.response?.data?.message || '게시글을 불러오지 못했습니다.'
} finally { } finally {
@@ -65,6 +86,7 @@ async function submit() {
title: title.value.trim(), title: title.value.trim(),
content: content.value, content: content.value,
category, category,
notice: canNotice.value ? notice.value : false,
tagIds: selectedTagIds.value, tagIds: selectedTagIds.value,
} }
try { try {
@@ -92,18 +114,23 @@ onMounted(async () => {
<template> <template>
<section class="write"> <section class="write">
<h1>{{ isEdit ? '글 수정' : '글쓰기' }}</h1>
<form class="write-form" @submit.prevent="submit"> <form class="write-form" @submit.prevent="submit">
<input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" /> <input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" />
<!-- 공지 등록 (관리자, 커뮤니티 ) -->
<label v-if="canNotice" class="notice-check">
<input v-model="notice" type="checkbox" :disabled="loading" />
📌 공지로 등록 (목록 최상단 고정)
</label>
<!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) --> <!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) -->
<div class="tag-select"> <div class="tag-select">
<div v-if="!allTags.length" class="hint"> <div v-if="!displayTags.length" class="hint">
등록된 태그가 없습니다. (관리자가 [태그 관리]에서 추가 있습니다) 게시판에 사용할 태그가 없습니다. (관리자가 [태그 관리]에서 게시판에 매핑 있습니다)
</div> </div>
<button <button
v-for="t in allTags" v-for="t in displayTags"
:key="t.id" :key="t.id"
type="button" type="button"
class="tag-badge" class="tag-badge"
@@ -178,6 +205,17 @@ h1 {
font-size: 0.85rem; font-size: 0.85rem;
opacity: 0.65; opacity: 0.65;
} }
.notice-check {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
cursor: pointer;
}
.notice-check input {
width: auto;
cursor: pointer;
}
.buttons { .buttons {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+209
View File
@@ -0,0 +1,209 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi'
import { formatRelative } from '@/utils/datetime'
import { boardLabel } from '@/constants/boards'
const router = useRouter()
const tab = ref('posts') // posts | comments
const items = ref([])
const page = ref(1)
const totalPages = ref(0)
const loading = ref(false)
const error = ref(null)
async function load() {
loading.value = true
error.value = null
try {
const res = tab.value === 'posts'
? await boardApi.myPosts({ page: page.value, size: 10 })
: await boardApi.myComments({ page: page.value, size: 10 })
items.value = res.content
totalPages.value = res.totalPages
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function switchTab(t) {
if (tab.value === t) return
tab.value = t
page.value = 1
}
function goPage(p) {
if (p < 1 || p > totalPages.value || p === page.value) return
page.value = p
}
function openPost(category, postId) {
router.push(`/board/${category}/${postId}`)
}
watch([tab, page], load)
onMounted(load)
</script>
<template>
<section class="myboard">
<div class="tabs">
<button type="button" class="tab" :class="{ active: tab === 'posts' }" @click="switchTab('posts')"> </button>
<button type="button" class="tab" :class="{ active: tab === 'comments' }" @click="switchTab('comments')"> 댓글</button>
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<template v-else>
<ul v-if="items.length" class="list">
<!-- -->
<template v-if="tab === 'posts'">
<li v-for="p in items" :key="p.id" class="item" @click="openPost(p.category, p.id)">
<div class="line1">
<span class="badge">{{ boardLabel(p.category) }}</span>
<span class="title">{{ p.title }}</span>
</div>
<div class="line2">
<span>{{ formatRelative(p.createdAt) }}</span>
<span>· 조회 {{ p.viewCount }}</span>
<span>· 추천 {{ p.upCount || 0 }}</span>
<span v-if="p.commentCount">· 댓글 {{ p.commentCount }}</span>
</div>
</li>
</template>
<!-- 댓글 -->
<template v-else>
<li v-for="c in items" :key="c.id" class="item" @click="openPost(c.category, c.postId)">
<div class="line1">
<span class="badge">{{ boardLabel(c.category) }}</span>
<span class="title">{{ c.postTitle }}</span>
</div>
<div class="comment-text">{{ c.content }}</div>
<div class="line2"><span>{{ formatRelative(c.createdAt) }}</span></div>
</li>
</template>
</ul>
<p v-else class="msg">{{ tab === 'posts' ? '작성한 글이 없습니다.' : '작성한 댓글이 없습니다.' }}</p>
<nav v-if="totalPages > 1" class="pagination">
<button type="button" :disabled="page <= 1" @click="goPage(page - 1)"></button>
<span class="page-info">{{ page }} / {{ totalPages }}</span>
<button type="button" :disabled="page >= totalPages" @click="goPage(page + 1)"></button>
</nav>
</template>
</section>
</template>
<style scoped>
.myboard {
max-width: 800px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.6rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
.tab.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.08);
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.item {
padding: 0.85rem 0.5rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.item:hover {
background: var(--color-background-soft);
}
.line1 {
display: flex;
align-items: center;
gap: 0.45rem;
}
.badge {
flex: none;
font-size: 0.7rem;
padding: 0.08rem 0.4rem;
border-radius: 4px;
background: var(--color-background-mute);
color: var(--color-text);
opacity: 0.8;
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-text {
margin-top: 0.3rem;
font-size: 0.9rem;
opacity: 0.85;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line2 {
margin-top: 0.3rem;
font-size: 0.8rem;
opacity: 0.6;
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.25rem;
}
.pagination button {
width: 36px;
height: 36px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
}
.pagination button:disabled {
opacity: 0.4;
cursor: default;
}
.page-info {
font-size: 0.9rem;
}
.msg {
margin: 1.5rem 0;
text-align: center;
opacity: 0.7;
}
.msg.error {
color: #c0392b;
}
</style>
+721 -29
View File
@@ -1,47 +1,267 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
import { billingApi } from '@/api/billingApi'
import { billing } from '@/native/billing'
import { BILLING_ENABLED } from '@/config/features'
import { useDialog } from '@/composables/dialog'
import { avatarSrc, avatarInitial, fileToAvatarDataUrl } from '@/utils/avatar'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const dialog = useDialog()
const u = computed(() => auth.user || {}) // 포인트(게시판 작성으로 수시 변동) — 진입 시 최신값 조회
const isLocal = computed(() => (u.value.provider || 'LOCAL') === 'LOCAL') const points = ref(auth.user?.points ?? 0)
onMounted(async () => {
const providerLabel = computed(() => { try {
switch (u.value.provider) { const res = await authApi.points()
case 'NAVER': return '네이버' points.value = res.points ?? 0
case 'LOCAL': await auth.applyUser({ points: points.value })
default: return '일반(아이디/비밀번호)' } catch {
// 조회 실패 시 캐시값 유지
} }
}) })
// 포인트 적립내역 모달
const POINT_REASONS = { BOARD_WRITE: '게시판 작성' }
function reasonLabel(r) {
return POINT_REASONS[r] || r
}
const historyOpen = ref(false)
const history = ref([])
const historyLoading = ref(false)
async function openHistory() {
historyOpen.value = true
historyLoading.value = true
try {
history.value = await authApi.pointHistory()
} catch {
history.value = []
} finally {
historyLoading.value = false
}
}
function fmtDateTime(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`
}
const u = computed(() => auth.user || {})
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원')) const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
const isPremium = computed(() => auth.isPremium)
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
// ===== 정기결제(구독) 관리 =====
const isNative = billing.isNative()
const sub = ref(null) // { premium, planLabel, expiresAt, autoRenew, nextBillingAt }
const products = ref([])
const subBusy = ref('')
const subMsg = ref('')
const subErr = ref('')
async function loadSubscription() {
try {
const [s, p] = await Promise.all([billingApi.subscription(), billingApi.products()])
sub.value = s
products.value = p
} catch {
sub.value = null
}
}
async function cancelSub() {
const ok = await dialog.confirm(
'구독을 해지하시겠어요?\n\n· 지금 해지해도 만료일까지는 프리미엄을 그대로 이용할 수 있어요.\n· 만료일 이후 자동으로 무료로 전환됩니다.\n· 만료 전에는 언제든 다시 구독(재개)할 수 있어요.',
{ title: '구독 해지', danger: true },
)
if (!ok) return
subBusy.value = 'cancel'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.cancel()
subMsg.value = '구독이 해지되었습니다. 만료일까지 이용 가능합니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '해지에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
async function resumeSub() {
subBusy.value = 'resume'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.resume()
subMsg.value = '구독이 재개되었습니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '재개에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
async function restoreSub() {
if (subBusy.value) return
subBusy.value = 'restore'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.restore()
await auth.refreshProfile()
subMsg.value = sub.value?.premium ? '구매가 복원되었습니다.' : '복원할 결제 내역이 없습니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '복원에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
// 포인트 기프트콘 교환 — 업데이트 예정
function exchangeGiftcon() {
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
}
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
async function choosePlan(product) {
if (!isNative) {
router.push('/upgrade')
return
}
if (subBusy.value) return
subBusy.value = product.id; subMsg.value = ''; subErr.value = ''
try {
const receipt = await billing.purchase(product.id)
await billingApi.verifyGoogle(receipt)
await auth.refreshProfile()
await loadSubscription()
subMsg.value = `${product.label}(으)로 적용되었습니다.`
} catch (e) {
subErr.value = e.response?.data?.message || '결제에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
// 결제 비활성화(첫 출시 무료 앱) 시 구독 현황/상품을 불러오지 않음 → 정기결제 카드 미노출
onMounted(() => {
if (BILLING_ENABLED) loadSubscription()
})
// 아바타: 사용자 지정 > 구글 > 이니셜
const avatar = computed(() => avatarSrc(u.value))
const initial = computed(() => avatarInitial(u.value))
const hasCustom = computed(() => !!u.value.profileImage)
const hasGoogle = computed(() => !!u.value.googlePicture)
const fileInput = ref(null)
const savingImage = ref(false)
const imgError = ref('')
function pickImage() {
imgError.value = ''
fileInput.value?.click()
}
async function onImageFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 다시 선택 가능하도록 초기화
if (!file) return
savingImage.value = true
imgError.value = ''
try {
const dataUrl = await fileToAvatarDataUrl(file)
const updated = await authApi.updateProfileImage(dataUrl)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 변경에 실패했습니다.'
} finally {
savingImage.value = false
}
}
async function removePhoto() {
const msg = hasGoogle.value
? '등록한 사진을 삭제할까요?\n구글 계정 사진으로 표시됩니다.'
: '등록한 사진을 삭제할까요?'
if (!(await dialog.confirm(msg, { title: '사진 삭제', danger: true }))) return
savingImage.value = true
imgError.value = ''
try {
const updated = await authApi.updateProfileImage(null)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 삭제에 실패했습니다.'
} finally {
savingImage.value = false
}
}
function goEdit() { function goEdit() {
router.push('/settings/account/edit') router.push('/settings/account/edit')
} }
function goBack() {
router.push('/settings') // ===== 회원 탈퇴 =====
const withdrawing = ref(false)
async function withdraw() {
if (withdrawing.value) return
const ok = await dialog.confirm(
'정말 탈퇴하시겠어요?\n\n· 계정과 모든 데이터(가계부·계좌·게시글·댓글·포인트·결제내역)가 영구 삭제됩니다.\n· 삭제된 데이터는 복구할 수 없습니다.',
{ title: '회원 탈퇴', danger: true },
)
if (!ok) return
const ok2 = await dialog.confirm('마지막 확인입니다. 정말 탈퇴를 진행할까요?', { title: '회원 탈퇴', danger: true })
if (!ok2) return
withdrawing.value = true
try {
await authApi.withdraw()
await auth.clear()
router.replace('/')
dialog.alert('탈퇴가 완료되었습니다. 그동안 이용해 주셔서 감사합니다.')
} catch (e) {
withdrawing.value = false
alert(e.response?.data?.message || '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.')
}
} }
</script> </script>
<template> <template>
<div class="account-info"> <div class="account-info">
<button type="button" class="back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
</svg>
<span>설정</span>
</button>
<h1 class="page-title">계정정보</h1> <!-- 프로필 사진 -->
<div class="avatar-block">
<button type="button" class="avatar-btn" :disabled="savingImage" @click="pickImage" aria-label="프로필 사진 변경">
<img v-if="avatar" :src="avatar" class="avatar-img" alt="프로필 사진" referrerpolicy="no-referrer" />
<span v-else class="avatar-fallback">{{ initial }}</span>
<span class="avatar-cam" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</span>
</button>
<div class="avatar-actions">
<button type="button" class="link-btn" :disabled="savingImage" @click="pickImage">
{{ savingImage ? '처리 ' : '사진 변경' }}
</button>
<button v-if="hasCustom" type="button" class="link-btn muted" :disabled="savingImage" @click="removePhoto">
삭제
</button>
</div>
<p v-if="imgError" class="avatar-err">{{ imgError }}</p>
<input ref="fileInput" type="file" accept="image/*" class="hidden-file" @change="onImageFile" />
</div>
<section class="card"> <section class="card">
<div class="row">
<span class="k">아이디</span>
<span class="v">{{ u.loginId || '-' }}</span>
</div>
<div class="row"> <div class="row">
<span class="k">이름</span> <span class="k">이름</span>
<span class="v">{{ u.name || '-' }}</span> <span class="v">{{ u.name || '-' }}</span>
@@ -50,18 +270,134 @@ function goBack() {
<span class="k">이메일</span> <span class="k">이메일</span>
<span class="v">{{ u.email || '-' }}</span> <span class="v">{{ u.email || '-' }}</span>
</div> </div>
<div class="row">
<span class="k">가입유형</span>
<span class="v">{{ providerLabel }}</span>
</div>
<div class="row"> <div class="row">
<span class="k">권한</span> <span class="k">권한</span>
<span class="v">{{ roleLabel }}</span> <span class="v">{{ roleLabel }}</span>
</div> </div>
<div class="row">
<span class="k">멤버십</span>
<span class="v">
<span :class="{ premium: isPremium }">{{ planLabel }}</span>
<RouterLink v-if="!isPremium && BILLING_ENABLED" to="/upgrade" class="up-link">업그레이드 </RouterLink>
</span>
</div>
<button type="button" class="row row-point" @click="openHistory">
<span class="k">포인트</span>
<span class="v">
<span class="points">{{ points.toLocaleString('ko-KR') }} P</span>
<span class="point-more">내역 </span>
</span>
</button>
<button type="button" class="row row-point" @click="exchangeGiftcon">
<span class="k">기프트콘 교환</span>
<span class="v"><span class="soon">업데이트 예정</span></span>
</button>
</section> </section>
<button v-if="isLocal" type="button" class="primary-btn" @click="goEdit">정보변경</button> <!-- 정기결제 관리 (결제 비활성화 출시에는 미노출) -->
<p v-else class="hint">소셜 로그인 계정은 앱에서 가입정보를 변경할 없습니다.</p> <section v-if="BILLING_ENABLED" class="card sub-card">
<h2 class="sub-title">정기결제 관리</h2>
<template v-if="sub && sub.premium">
<div class="row">
<span class="k">결제 플랜</span>
<span class="v">{{ sub.planLabel || '프리미엄' }}</span>
</div>
<div class="row">
<span class="k">만료일</span>
<span class="v">{{ fmtDate(sub.expiresAt) || '-' }}</span>
</div>
<div class="row">
<span class="k">다음 결제일</span>
<span class="v">
<template v-if="sub.autoRenew">{{ fmtDate(sub.nextBillingAt) || '-' }}</template>
<span v-else class="canceled">해지됨 · 만료일까지 이용</span>
</span>
</div>
<!-- 해지/재개 -->
<div class="sub-actions">
<button v-if="sub.autoRenew" type="button" class="sub-btn danger" :disabled="!!subBusy" @click="cancelSub">
{{ subBusy === 'cancel' ? '처리 ' : '구독 해지' }}
</button>
<template v-else>
<p class="sub-note">구독이 해지되어 <b>{{ fmtDate(sub.expiresAt) }}</b>까지 이용 무료로 전환됩니다.</p>
<button type="button" class="sub-btn" :disabled="!!subBusy" @click="resumeSub">
{{ subBusy === 'resume' ? '처리 ' : '구독 재개' }}
</button>
</template>
</div>
</template>
<template v-else>
<p class="sub-free">현재 구독 중이 아닙니다. 프리미엄으로 많은 기능을 이용해 보세요.</p>
</template>
<!-- 플랜 선택/변경 -->
<div v-if="products.length" class="plan-list">
<p class="plan-list-title">{{ sub && sub.premium ? '플랜 변경' : '구독 플랜' }}</p>
<button
v-for="p in products"
:key="p.id"
type="button"
class="plan-row"
:class="{ current: sub && sub.planProduct === p.id }"
:disabled="!!subBusy || (sub && sub.planProduct === p.id)"
@click="choosePlan(p)"
>
<span class="plan-name">{{ p.label }}</span>
<span class="plan-price">{{ p.priceKrw.toLocaleString('ko-KR') }}<span class="plan-per"> / {{ p.months }}개월</span></span>
<span class="plan-cta">
<template v-if="sub && sub.planProduct === p.id">현재 플랜</template>
<template v-else-if="subBusy === p.id">처리 </template>
<template v-else-if="!isNative">앱에서 </template>
<template v-else-if="sub && sub.premium">변경하기</template>
<template v-else>구독하기</template>
</span>
</button>
<p v-if="!isNative" class="sub-note">📱 구독 결제·변경은 모바일 앱에서 진행됩니다.</p>
</div>
<div class="restore-row">
<button type="button" class="link-btn muted" :disabled="!!subBusy" @click="restoreSub">
{{ subBusy === 'restore' ? '복원 ' : '구매 복원' }}
</button>
</div>
<p v-if="subMsg" class="sub-ok">{{ subMsg }}</p>
<p v-if="subErr" class="sub-errmsg">{{ subErr }}</p>
</section>
<!-- 포인트 적립내역 모달 -->
<Teleport to="body">
<div v-if="historyOpen" class="ph-backdrop" @click.self="historyOpen = false">
<div class="ph-modal" role="dialog" aria-modal="true" aria-label="포인트 적립내역">
<div class="ph-head">
<strong>포인트 적립내역</strong>
<button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button>
</div>
<p v-if="historyLoading" class="ph-msg">불러오는 </p>
<p v-else-if="!history.length" class="ph-msg">적립 내역이 없습니다.</p>
<ul v-else class="ph-list">
<li v-for="(h, i) in history" :key="i">
<div class="ph-main">
<span class="ph-reason">{{ reasonLabel(h.reason) }}</span>
<span class="ph-date">{{ fmtDateTime(h.createdAt) }}</span>
</div>
<span class="ph-amount" :class="{ minus: h.amount < 0 }">{{ h.amount > 0 ? '+' : '' }}{{ h.amount }} P</span>
</li>
</ul>
</div>
</div>
</Teleport>
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
<div class="withdraw-area">
<button type="button" class="withdraw-link" :disabled="withdrawing" @click="withdraw">
{{ withdrawing ? '처리 ' : '회원 탈퇴' }}
</button>
</div>
</div> </div>
</template> </template>
@@ -121,6 +457,341 @@ function goBack() {
text-align: right; text-align: right;
word-break: break-all; word-break: break-all;
} }
.v .premium {
color: #b8860b;
}
.points {
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.soon {
font-size: 0.74rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-background-mute);
color: var(--color-text);
opacity: 0.7;
}
.restore-row {
padding: 0.4rem 1.1rem 0.2rem;
text-align: right;
}
.row-point {
width: 100%;
border: 0;
border-bottom: 1px solid var(--color-border);
background: transparent;
cursor: pointer;
font: inherit;
text-align: left;
}
.row-point:last-child {
border-bottom: 0;
}
.row-point:hover {
background: var(--color-background-mute);
}
.point-more {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
opacity: 0.55;
}
/* 정기결제 관리 */
.sub-card {
margin-top: 1rem;
padding: 0.5rem 0;
}
.sub-title {
font-size: 0.92rem;
font-weight: 700;
padding: 0.6rem 1.1rem 0.3rem;
color: var(--color-heading);
}
.canceled {
color: #c0392b;
font-size: 0.88rem;
}
.sub-free {
padding: 0.9rem 1.1rem;
font-size: 0.9rem;
opacity: 0.85;
}
.sub-actions {
padding: 0.75rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-top: 1px solid var(--color-border);
}
.sub-note {
font-size: 0.83rem;
opacity: 0.75;
line-height: 1.5;
}
.sub-btn {
align-self: flex-start;
padding: 0.5rem 1.2rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: var(--color-background);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
cursor: pointer;
}
.sub-btn.danger {
border-color: #c0392b;
color: #c0392b;
}
.sub-btn:disabled {
opacity: 0.55;
cursor: default;
}
.plan-list {
padding: 0.75rem 1.1rem;
border-top: 1px solid var(--color-border);
}
.plan-list-title {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.8;
margin-bottom: 0.5rem;
}
.plan-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 0.8rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.plan-row:last-child {
margin-bottom: 0;
}
.plan-row.current {
border-color: hsla(160, 100%, 37%, 0.7);
background: hsla(160, 100%, 37%, 0.06);
}
.plan-row:disabled {
cursor: default;
}
.plan-name {
font-weight: 700;
}
.plan-price {
margin-left: auto;
font-weight: 700;
color: #b8860b;
}
.plan-per {
font-weight: 400;
font-size: 0.8rem;
opacity: 0.7;
}
.plan-cta {
min-width: 56px;
text-align: right;
font-size: 0.85rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.plan-row.current .plan-cta {
color: var(--color-text);
opacity: 0.6;
}
.sub-ok {
padding: 0 1.1rem 0.6rem;
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
font-size: 0.88rem;
}
.sub-errmsg {
padding: 0 1.1rem 0.6rem;
color: #c0392b;
font-size: 0.88rem;
}
/* 포인트 내역 모달 */
.ph-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.ph-modal {
width: 100%;
max-width: 380px;
max-height: 75vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.ph-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.ph-close {
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.ph-msg {
padding: 2rem 1rem;
text-align: center;
opacity: 0.7;
}
.ph-list {
list-style: none;
margin: 0;
padding: 0.25rem 0;
overflow-y: auto;
}
.ph-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.ph-list li:last-child {
border-bottom: 0;
}
.ph-main {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.ph-reason {
font-size: 0.92rem;
}
.ph-date {
font-size: 0.78rem;
opacity: 0.6;
}
.ph-amount {
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
white-space: nowrap;
}
.ph-amount.minus {
color: #c0392b;
}
.up-link {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.avatar-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.avatar-btn {
position: relative;
width: 96px;
height: 96px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
padding: 0;
cursor: pointer;
overflow: visible;
}
.avatar-btn:disabled {
opacity: 0.6;
cursor: default;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
font-size: 2.2rem;
font-weight: 700;
color: #fff;
background: hsla(160, 100%, 37%, 1);
}
.avatar-cam {
position: absolute;
right: -2px;
bottom: -2px;
width: 30px;
height: 30px;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
border: 2px solid var(--color-background);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.avatar-cam svg {
width: 16px;
height: 16px;
}
.avatar-actions {
display: flex;
gap: 0.85rem;
align-items: center;
}
.link-btn {
border: 0;
background: transparent;
color: hsla(160, 100%, 37%, 1);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.2rem 0.1rem;
}
.link-btn.muted {
color: var(--color-text);
opacity: 0.6;
}
.link-btn:disabled {
opacity: 0.5;
cursor: default;
}
.avatar-err {
font-size: 0.82rem;
color: #c0392b;
margin: 0;
}
.hidden-file {
display: none;
}
.primary-btn { .primary-btn {
width: 100%; width: 100%;
margin-top: 1.25rem; margin-top: 1.25rem;
@@ -133,6 +804,27 @@ function goBack() {
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.withdraw-area {
margin-top: 1.5rem;
text-align: center;
}
.withdraw-link {
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.5;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.withdraw-link:hover {
color: #c0392b;
opacity: 1;
}
.withdraw-link:disabled {
opacity: 0.4;
cursor: default;
}
.primary-btn:hover { .primary-btn:hover {
background: hsla(160, 100%, 32%, 1); background: hsla(160, 100%, 32%, 1);
} }
+29 -29
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref, onMounted } from 'vue' import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi' import { authApi } from '@/api/authApi'
@@ -7,17 +7,22 @@ import { authApi } from '@/api/authApi'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const step = ref('verify') // 'verify' → 'edit' // 소셜(구글 등) 계정은 비밀번호가 없어 재인증 없이 이름만 변경
const isSocial = computed(() => (auth.user?.provider || 'LOCAL') !== 'LOCAL')
const step = ref('verify') // 'verify' → 'edit' (소셜은 곧바로 edit)
const password = ref('') // verify 단계에서 입력한 현재 비밀번호 (비밀번호 변경 시 재사용) const password = ref('') // verify 단계에서 입력한 현재 비밀번호 (비밀번호 변경 시 재사용)
const form = reactive({ name: '', email: '' }) const form = reactive({ name: '', email: '' })
const pw = reactive({ next: '', confirm: '' }) // 비밀번호 변경(선택) const pw = reactive({ next: '', confirm: '' }) // 비밀번호 변경(선택, LOCAL 전용)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
onMounted(() => { onMounted(() => {
// 소셜 계정 비밀번호 인증 불가 → 계정정보로 돌려보냄 // 소셜 계정: 비밀번호 인증 단계 건너뛰고 바로 이름 변경 화면으로
if ((auth.user?.provider || 'LOCAL') !== 'LOCAL') { if (isSocial.value) {
router.replace('/settings/account') form.name = auth.user?.name || ''
form.email = auth.user?.email || ''
step.value = 'edit'
} }
}) })
@@ -48,13 +53,14 @@ async function save() {
error.value = '이름을 입력하세요.' error.value = '이름을 입력하세요.'
return return
} }
const email = form.email.trim() // 소셜 계정은 이메일(구글 제공) 유지, LOCAL 만 폼에서 편집
const email = isSocial.value ? (auth.user?.email || null) : (form.email.trim() || null)
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
error.value = '이메일 형식이 올바르지 않습니다.' error.value = '이메일 형식이 올바르지 않습니다.'
return return
} }
// 비밀번호 변경(선택) — 새 비밀번호를 입력한 경우에만 검증/적용 // 비밀번호 변경(선택, LOCAL 전용) — 새 비밀번호를 입력한 경우에만 검증/적용
const changePw = !!pw.next const changePw = !isSocial.value && !!pw.next
if (changePw) { if (changePw) {
if (pw.next.length < 8 || pw.next.length > 64) { if (pw.next.length < 8 || pw.next.length > 64) {
error.value = '새 비밀번호는 8~64자여야 합니다.' error.value = '새 비밀번호는 8~64자여야 합니다.'
@@ -93,14 +99,6 @@ function cancel() {
<template> <template>
<div class="profile-edit"> <div class="profile-edit">
<button type="button" class="back" @click="cancel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
</svg>
<span>계정정보</span>
</button>
<h1 class="page-title">가입정보 변경</h1>
<!-- 1단계: 비밀번호 재인증 --> <!-- 1단계: 비밀번호 재인증 -->
<form v-if="step === 'verify'" class="card form" @submit.prevent="verify"> <form v-if="step === 'verify'" class="card form" @submit.prevent="verify">
@@ -120,7 +118,7 @@ function cancel() {
<!-- 2단계: 가입정보 변경 --> <!-- 2단계: 가입정보 변경 -->
<form v-else class="card form" @submit.prevent="save"> <form v-else class="card form" @submit.prevent="save">
<label class="field"> <label v-if="!isSocial" class="field">
<span class="flabel">아이디</span> <span class="flabel">아이디</span>
<input :value="auth.user?.loginId" type="text" disabled /> <input :value="auth.user?.loginId" type="text" disabled />
<span class="fnote">아이디는 변경할 없습니다.</span> <span class="fnote">아이디는 변경할 없습니다.</span>
@@ -129,21 +127,23 @@ function cancel() {
<span class="flabel">이름</span> <span class="flabel">이름</span>
<input v-model="form.name" type="text" maxlength="100" placeholder="이름" :disabled="loading" /> <input v-model="form.name" type="text" maxlength="100" placeholder="이름" :disabled="loading" />
</label> </label>
<label class="field"> <label v-if="!isSocial" class="field">
<span class="flabel">이메일</span> <span class="flabel">이메일</span>
<input v-model="form.email" type="email" maxlength="255" placeholder="이메일 (선택)" :disabled="loading" /> <input v-model="form.email" type="email" maxlength="255" placeholder="이메일 (선택)" :disabled="loading" />
</label> </label>
<hr class="divider" /> <template v-if="!isSocial">
<p class="section-label">비밀번호 변경 <span class="opt">(변경 시에만 입력)</span></p> <hr class="divider" />
<label class="field"> <p class="section-label">비밀번호 변경 <span class="opt">(변경 시에만 입력)</span></p>
<span class="flabel"> 비밀번호</span> <label class="field">
<input v-model="pw.next" type="password" autocomplete="new-password" maxlength="64" placeholder="새 비밀번호 (8~64자)" :disabled="loading" /> <span class="flabel"> 비밀번호</span>
</label> <input v-model="pw.next" type="password" autocomplete="new-password" maxlength="64" placeholder="새 비밀번호 (8~64자)" :disabled="loading" />
<label class="field"> </label>
<span class="flabel"> 비밀번호 확인</span> <label class="field">
<input v-model="pw.confirm" type="password" autocomplete="new-password" maxlength="64" placeholder="새 비밀번호 확인" :disabled="loading" /> <span class="flabel"> 비밀번호 확인</span>
</label> <input v-model="pw.confirm" type="password" autocomplete="new-password" maxlength="64" placeholder="새 비밀번호 확인" :disabled="loading" />
</label>
</template>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<div class="actions"> <div class="actions">
+422 -2
View File
@@ -1,13 +1,174 @@
<script setup> <script setup>
import { ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { exportBackup, importBackup } from '@/utils/backup'
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 auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
// ===== 알림(로컬) 설정 — 앱에서만 =====
const notifSupported = reminders.isNative()
const notifEnabled = ref(false)
const notifHour = ref(21)
const notifSaving = ref(false)
function subFromUser() {
return { premium: auth.isPremium, autoRenew: !!auth.user?.planAutoRenew, expiresAt: auth.user?.planExpiresAt }
}
onMounted(async () => {
if (!notifSupported) return
const s = await reminders.getSettings()
notifEnabled.value = s.enabled
notifHour.value = s.hour
})
async function toggleNotif() {
if (notifSaving.value) return
const next = !notifEnabled.value
notifSaving.value = true
try {
if (next) {
const ok = await reminders.ensurePermission()
if (!ok) {
await dialog.alert('알림 권한이 꺼져 있어요. 기기 설정에서 알림을 허용해 주세요.')
return
}
}
notifEnabled.value = next
await reminders.setSettings({ enabled: next, hour: notifHour.value })
if (next) {
await reminders.scheduleEntryReminder(false)
await reminders.scheduleExpiryReminder(subFromUser())
} else {
await reminders.cancelAll()
}
} finally {
notifSaving.value = false
}
}
async function changeNotifHour(v) {
notifHour.value = Number(v)
await reminders.setSettings({ enabled: notifEnabled.value, hour: notifHour.value })
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)
async function doExport() {
if (exporting.value) return
exporting.value = true
try {
await exportBackup()
} catch (e) {
window.alert(e?.message || '내보내기에 실패했습니다.')
} finally {
exporting.value = false
}
}
const importing = ref(false)
const fileInput = ref(null)
function pickImportFile() {
if (importing.value) return
fileInput.value?.click()
}
async function onImportFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 다시 선택 가능하게 초기화
if (!file) return
const ok = await dialog.confirm(
'가져오기를 하면 현재 가계부의 모든 데이터가 백업 파일 내용으로 교체됩니다.\n(투자 종목·매매내역은 백업에 없어 사라집니다)\n계속하시겠습니까?',
{ title: '데이터 가져오기', okText: '가져오기', danger: true },
)
if (!ok) return
importing.value = true
try {
await importBackup(file)
await dialog.alert('가져오기가 완료되었습니다. 화면을 새로고침합니다.')
window.location.reload()
} catch (err) {
window.alert(err?.response?.data?.message || err?.message || '가져오기에 실패했습니다.')
} finally {
importing.value = false
}
}
const THEMES = [
{ value: 'system', label: '시스템' },
{ value: 'light', label: '라이트' },
{ value: 'dark', label: '다크' },
]
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-' const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
const clearing = ref(false) const clearing = ref(false)
// PC(데스크톱) 전용: 창 크기(가로·세로 각각) 변경 (Electron preload 의 window.desktop 존재 시에만)
const isDesktop = typeof window !== 'undefined' && !!window.desktop
const WIDTHS = [900, 1024, 1280, 1366, 1600, 1920]
const HEIGHTS = [700, 800, 900, 1000, 1080, 1200]
// 한 축만 바꾸고 나머지 축은 현재 창 크기를 유지
async function setDim(dim, value) {
const v = Number(value)
if (!v) return
let cur = [1024, 768]
try {
cur = (await window.desktop?.getWindowSize?.()) || cur
} catch {
/* 무시 */
}
const w = dim === 'w' ? v : cur[0]
const h = dim === 'h' ? v : cur[1]
window.desktop?.setWindowSize(w, h)
}
async function clearAppData() { async function clearAppData() {
if (clearing.value) return if (clearing.value) return
const ok = await dialog.confirm( const ok = await dialog.confirm(
@@ -35,7 +196,115 @@ async function clearAppData() {
<template> <template>
<div class="settings"> <div class="settings">
<h1 class="page-title">설정</h1>
<section class="card">
<div class="row">
<div class="row-main">
<span class="row-label">화면 테마</span>
<span class="row-sub">라이트 / 다크 모드 선택</span>
</div>
<div class="theme-seg">
<button
v-for="t in THEMES"
:key="t.value"
type="button"
class="seg-btn"
:class="{ active: ui.theme === t.value }"
@click="ui.setTheme(t.value)"
>{{ t.label }}</button>
</div>
</div>
</section>
<!-- 알림 ( 전용 로컬 알림) -->
<section v-if="notifSupported" class="card">
<div class="row">
<div class="row-main">
<span class="row-label">알림 받기</span>
<span class="row-sub">가계부 기록 리마인더 · 구독 만료 안내</span>
</div>
<button
type="button" class="switch" :class="{ on: notifEnabled }"
:disabled="notifSaving" role="switch" :aria-checked="notifEnabled"
@click="toggleNotif"
><span class="knob"></span></button>
</div>
<div v-if="notifEnabled" class="row">
<div class="row-main">
<span class="row-label">리마인더 시간</span>
<span class="row-sub">오늘 기록이 없으면 시각에 알려드려요</span>
</div>
<select class="res-select" :value="notifHour" @change="changeNotifHour($event.target.value)">
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ String(h - 1).padStart(2, '0') }}:00</option>
</select>
</div>
</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(데스크톱) 전용: 크기 (가로·세로 각각) -->
<section v-if="isDesktop" class="card">
<div class="row">
<div class="row-main">
<span class="row-label"> 크기</span>
<span class="row-sub">가로·세로를 각각 선택</span>
</div>
<div class="size-selects">
<select class="res-select" title="가로(폭)" @change="setDim('w', $event.target.value)">
<option value="">가로</option>
<option v-for="w in WIDTHS" :key="w" :value="w">{{ w }}</option>
</select>
<span class="x">×</span>
<select class="res-select" title="세로(높이)" @change="setDim('h', $event.target.value)">
<option value="">세로</option>
<option v-for="h in HEIGHTS" :key="h" :value="h">{{ h }}</option>
</select>
</div>
</div>
</section>
<!-- 데이터 백업 (유료 전용) -->
<section class="card">
<template v-if="isPremium">
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
<div class="row-main">
<span class="row-label">엑셀로 내보내기</span>
<span class="row-sub">가계부 전체 데이터를 .xlsx 파일로 저장</span>
</div>
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
</button>
<button type="button" class="row row-btn" :disabled="importing" @click="pickImportFile">
<div class="row-main">
<span class="row-label">엑셀에서 가져오기</span>
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
</div>
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
</button>
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
</template>
<RouterLink v-else to="/upgrade" class="row row-link">
<div class="row-main">
<span class="row-label">데이터 백업 / 복구 👑</span>
<span class="row-sub">엑셀 내보내기·가져오기는 유료 멤버십 기능입니다</span>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
</section>
<section class="card"> <section class="card">
<RouterLink to="/settings/account" class="row row-link"> <RouterLink to="/settings/account" class="row row-link">
@@ -48,6 +317,24 @@ async function clearAppData() {
</svg> </svg>
</RouterLink> </RouterLink>
<RouterLink to="/privacy" class="row row-link">
<div class="row-main">
<span class="row-label">개인정보처리방침</span>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
<RouterLink to="/terms" class="row row-link">
<div class="row-main">
<span class="row-label">이용약관</span>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
<div class="row"> <div class="row">
<div class="row-main"> <div class="row-main">
<span class="row-label"> 버전 정보</span> <span class="row-label"> 버전 정보</span>
@@ -65,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>
@@ -73,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;
@@ -132,6 +480,78 @@ async function clearAppData() {
.danger { .danger {
color: #c0392b; color: #c0392b;
} }
.theme-seg {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.size-selects {
display: flex;
align-items: center;
gap: 0.3rem;
flex: none;
}
.size-selects .x {
opacity: 0.5;
}
.res-select {
flex: none;
max-width: 90px;
}
.hidden-file {
display: none;
}
.switch {
flex-shrink: 0;
width: 48px;
height: 28px;
border: 0;
border-radius: 999px;
background: #b0b0b0;
position: relative;
cursor: pointer;
transition: background 0.18s;
padding: 0;
}
.switch.on {
background: hsla(160, 100%, 37%, 1);
}
.switch:disabled {
opacity: 0.6;
cursor: default;
}
.switch .knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
transition: transform 0.18s;
}
.switch.on .knob {
transform: translateX(20px);
}
.seg-btn {
padding: 0.4rem 0.7rem;
border: 0;
border-left: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
}
.seg-btn:first-child {
border-left: 0;
}
.seg-btn.active {
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 600;
}
.chevron { .chevron {
width: 18px; width: 18px;
height: 18px; height: 18px;