Compare commits

...

159 Commits

Author SHA1 Message Date
ByungCheol 21d79fa040 chore(android): versionCode 6, versionName 1.0.5
Deploy / deploy (push) Failing after 14m42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 03:35:31 +09:00
sb f738c6f5b1 docs: LAUNCH-PROGRESS 현행화 — 애플 로그인·iOS App Store 제출·UI 페이지/바텀시트 개편(07-09~11)
Deploy / deploy (push) Failing after 14m15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:56:50 +09:00
sb 75587e7645 chore(ios): Sign in with Apple/Push capability 반영 — CODE_SIGN_ENTITLEMENTS 연결
Deploy / deploy (push) Failing after 18m5s
- Xcode에서 capability 추가로 entitlements가 빌드에 포함되도록 프로젝트에 반영
- aps-environment(Push) entitlement 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:53:05 +09:00
sb f50f1b8fc5 chore: 연락처 이메일 → dondwaeji.app@gmail.com (약관·계정삭제·스토어 문의)
Deploy / deploy (push) Failing after 19m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:52:20 +09:00
sb f1dc11d402 chore(ios): ITSAppUsesNonExemptEncryption=false — App Store 수출규정 질문 생략(표준 HTTPS만 사용)
Deploy / deploy (push) Failing after 21m20s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 01:00:10 +09:00
sb f367379c84 chore(ios): iOS 번들ID kr.sblog.slimbudget.app 로 변경 — 애플 로그인 App ID 대응
Deploy / deploy (push) Failing after 22m12s
- 기존 kr.sblog.slimbudget 는 타 계정 선점 → iOS 전용 새 번들ID
- 안드로이드 패키지(kr.sblog.slimbudget)는 유지(플랫폼 독립)
- appleAuth clientId 정합(네이티브 aud=번들ID)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:43:08 +09:00
sb 42cfeccbaf refactor(nav): 화면 헤더 뒤로가기 버튼 제거 — 하단 네비 뒤로가기로 유도
Deploy / deploy (push) Failing after 14m3s
- 계좌 폼·정기결제 폼·계좌 상세·내역 폼·예산 폼·포인트 상점 헤더의 뒤로가기(←/‹) 제거
- 폼 취소 버튼·공개(약관/삭제) 페이지 돌아가기는 유지
- 뒤로가기는 하단 네비게이션 바(뒤로) 사용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:20:04 +09:00
sb 466fbe8136 refactor(budget): 예산 추가/수정 모달 → 인라인 페이지 + 분류 바텀시트
Deploy / deploy (push) Failing after 15m36s
- AppModal 제거, 목록/폼 v-if·v-else 전환(헤더·하단바 유지 + 뒤로가기)
- ?budget 라우트 바인딩으로 뒤로가기 시 닫힘
- 분류 선택은 트리거 → 바텀시트(CategoryPicker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:13:11 +09:00
ByungCheol 1a85e448aa feat(shop): 포인트 상점 프론트엔드 구현
Deploy / deploy (push) Failing after 11m25s
- accountApi: shopStatus(), buyShopItem(item) 추가; buyShopItem은 DEMO_WRITES에 등록
- router: /settings/shop → ShopView.vue 라우트 추가
- ShopView.vue: 포인트 잔액·상품 카드(계좌 슬롯/AI 통계) 구매 화면, PREMIUM 회원 안내 분기
- AccountInfoView: '기프트콘 교환' → '포인트 상점' 로 이름 변경 + 클릭 시 /settings/shop 이동
- AccountDashboardView: AI 코멘트 402 응답 시 크레딧 소진 안내 + 상점 이동 버튼 노출
- LAUNCH-PROGRESS: 포인트 상점  완료 표시, 다음 재개 지점 현행화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 00:33:13 +09:00
sb 00d2945e39 refactor(entries): 내역 추가/수정을 오버레이 모달 → 인라인 페이지로(톤 통일)
Deploy / deploy (push) Failing after 13m43s
- 폼을 Teleport 전체화면 모달 대신 콘텐츠 영역 인라인 페이지로 렌더
  (앱 헤더·하단바 유지 + 상단 뒤로가기, 계좌·정기결제 페이지와 동일 톤)
- 목록 뷰는 formOpen 시 숨김(v-if/v-else), ?entry 라우트 바인딩 유지
- 폼 로직(AI·OCR·상환 등)은 그대로 — 프레젠테이션만 변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:07:47 +09:00
sb 24db9b0587 feat(entries): 내역 추가/수정 폼을 라우트(?entry) 바인딩 — 뒤로가기로 닫히는 페이지 전환
Deploy / deploy (push) Failing after 14m57s
- openForm/closeForm: 폼 열 때 히스토리에 ?entry 추가, 닫을 때 뒤로가기로 URL 정리
- 뒤로가기(브라우저·앱 뒤로)로 폼 닫힘 → 페이지 전환처럼 동작
- AI 빠른입력·OCR·상환 로직은 목록에 그대로 두어 회귀 위험 없음

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:01:34 +09:00
ByungCheol 6f49a7eef0 feat(point): 포인트 reason 한글화 + 월별 조회 네비게이션
Deploy / deploy (push) Failing after 12m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:49:01 +09:00
sb cdaa7d09ac feat(filter): 검색 분류를 CategoryPicker(대분류→소분류)로, 추가 버튼만 숨김
Deploy / deploy (push) Failing after 12m11s
- CategoryPicker에 hideAdd prop 추가(+대분류/+소분류 숨김)
- 내역 검색 필터 분류: 평면 칩 → 트리거→시트(CategoryPicker hide-add) + '분류 전체'
- 타입은 구분 필터(기본 지출) 기준

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:44:19 +09:00
sb 2ee5cae6ea feat(ui): 계좌 선택을 종류→계좌 아코디언(AccountPicker)으로
Deploy / deploy (push) Failing after 11m53s
- AccountPicker.vue: 종류(대분류) 클릭 시 해당 계좌(소분류) 펼침. CategoryPicker 형태
- 정기결제 폼 출금/입금 계좌: 종류칩+시트 → AccountPicker 단일 선택
- 내역 검색 필터 계좌, 상환 대상: AccountPicker 적용(필터는 '전체' 옵션)
- 계좌 많아도 종류별로 접혀 스크롤 최소화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:34:36 +09:00
sb 2d70fced79 style(ui): SheetSelect 칩을 CategoryPicker 그리드(4열 균일 박스)로 통일
Deploy / deploy (push) Failing after 11m55s
- pill 칩 → 4열 균일 박스(.acc-chip 동일), 활성은 연한 초록. 목록/셀렉트보다 깔끔

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:19:35 +09:00
sb ecc3ef0f4e feat(ui): SheetSelect 시트 내용을 리스트 → 칩 그리드(CategoryPicker 형태)
Deploy / deploy (push) Successful in 33s
- 셀렉트형 선택(계좌·태그·구분 등)을 바텀시트 안에서 칩 그리드로 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:13:24 +09:00
sb c7875b0577 feat(entries): 검색 필터·내역 폼 선택을 바텀시트로
Deploy / deploy (push) Failing after 14m48s
- 검색 필터(구분·분류·계좌·태그) select → SheetSelect(바텀시트)
- 내역 추가/수정 폼: 계좌·상환대상 ChipSelect → SheetSelect,
  분류 CategoryPicker → 트리거 → 바텀시트(선택 시 자동 닫힘)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:06:42 +09:00
sb d20d3c3027 feat(ui): 계좌/분류 선택을 바텀시트로 (정기결제 폼)
Deploy / deploy (push) Failing after 13m8s
- BottomSheet.vue: 아래에서 올라오는 콘텐츠 높이 시트(재사용)
- SheetSelect.vue: 필드형 트리거 → 바텀시트 단일 선택(계좌/카드)
- 정기결제 폼: 계좌는 SheetSelect, 분류는 트리거 → 시트 안 CategoryPicker
  (선택 전에는 목록 미노출, 선택 시 값 채움)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:53:23 +09:00
sb ed607e1cda refactor(recurring): 정기결제 추가/수정을 모달 → 별도 화면(화면전환)
Deploy / deploy (push) Failing after 15m19s
- RecurringFormView.vue 신규: /account/recurrings/new · /:id/edit
- RecurringView: 목록 전용으로 정리(폼·모달·계좌/분류 로직 제거), +/수정은 화면 이동
- 무료 한도 초과 시 업셀은 목록에서 확인 후 이동

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:46:11 +09:00
ByungCheol 641d0649ae fix(settings): 데이터 초기화 모달 닫은 후 confirm 표시 — 팝업 겹침·클릭 막힘 해결
Deploy / deploy (push) Failing after 12m0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 22:44:07 +09:00
ByungCheol a4e0b38b2f feat(settings): 가계부 데이터 초기화 기능 — 엑셀 백업 안내·선택·진행상태바·완료토스트
Deploy / deploy (push) Successful in 34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 22:39:17 +09:00
sb ca09dce2bd refactor(wallets): 계좌 추가/수정을 모달 → 별도 화면(화면전환)
Deploy / deploy (push) Failing after 12m40s
- WalletFormView.vue 신규: /account/wallets/new · /:id/edit 라우트
- 생성 시 '계좌 종류' 선택 UI 복원(탭 제거 후 빠졌던 부분)
- AccountWalletView: 모달·폼 로직 제거, +/수정 버튼은 화면 이동
- 상세 라우트는 :id(\d+)로 제한(new와 충돌 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:33:50 +09:00
sb 13a0f0d40f fix(layout): 본문 하단에 고정 내비 높이만큼 여백 — 마지막 내용 가림 해결
Deploy / deploy (push) Failing after 13m57s
- 하단 내비가 position:fixed 인데 본문 하단 여백은 광고 있을 때만 있어,
  광고 OFF 상태에서 마지막 내용/버튼이 내비 뒤로 가려져 클릭 불가
- .layout-body 에 기본 padding-bottom(56px+safe-area) 항상 적용(PC·모바일)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:17:31 +09:00
sb 28712a026d feat(budget): 예산 추가를 '월 예산'만으로 단순화 + 1회성 예산 제거
Deploy / deploy (push) Failing after 12m24s
- 예산 폼: 고정/비고정 방식·일/주/년 예산 입력 제거 → 월 예산 단일 입력
- 1회성 예산 섹션·모달·관련 로직 전체 제거(백엔드 엔드포인트는 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:14:05 +09:00
ByungCheol d63c6997c9 build(android): versionCode 5, versionName 1.0.4 — Play Store v5 AAB 빌드
Deploy / deploy (push) Failing after 10m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:55:28 +09:00
sb d8326568ff docs: LAUNCH-PROGRESS 현행화 — 07-07 배포 완료(정기결제·계좌개편·상환조회·월상환금·홈 상환예정)
Deploy / deploy (push) Failing after 10m58s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:50:32 +09:00
ByungCheol 486942b1c9 docs: 2026-07-07 작업 현행화 — 고정지출주기·태그순서·PMT·교통팁·상환일정·포인트상점견적
Deploy / deploy (push) Failing after 11m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:44:59 +09:00
sb ba7ffaff92 fix(entry): 대출 상환 자동세팅에 월 상환금(loanPayment) 우선 반영
Deploy / deploy (push) Failing after 13m58s
- 가계부에서 원리금균등 대출 선택 시 최초원금 PMT 추정값(옛 방식) 대신
  저장된 월 상환금을 그대로 사용 → 은행/상환표와 일치
- 잔액이 작은 마지막 회차는 원금을 잔액까지만(초과 상환 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:42:33 +09:00
sb 5567a239df chore(home): 이번 달 상환 예정 카드 유료 전용 — 무료는 계산·노출 생략
Deploy / deploy (push) Failing after 13m1s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:38:05 +09:00
sb 2b58e976fc feat(home): 대시보드에 이번 달 상환 예정 금액 카드
Deploy / deploy (push) Successful in 34s
- 대출 월 상환금 + 카드 할부(이번 달 회차) 합계를 홈 대시보드에 표기
- 현재일 기준, 가계부에 상환 기록(REPAYMENT) 있는 계좌는 제외
- 모두 기록 시 '이번 달 상환 완료' 표시, 부채 없으면 카드 숨김
- 계산은 utils/repayment.js 재사용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:37:03 +09:00
sb e4ab683ac8 feat(wallet): 대출 연이자율 소수점 3자리 입력 허용 (step 0.001)
Deploy / deploy (push) Failing after 15m2s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:31:27 +09:00
sb a5264f4dff fix(repayment): 원리금균등 마지막 회차에 잔여 반올림 합산 — 꼬리 회차 제거
Deploy / deploy (push) Failing after 14m27s
- 매월 이자 반올림 누적으로 마지막 정상 회차 뒤 수십원(원금 62·이자 1) 추가 회차
  생기던 문제. 잔액<=월상환금이면 마지막 회차로 남은 잔액을 한 번에 정산(은행 동일)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:22:02 +09:00
sb 18f015f7fd feat(repayment): 대출 월 상환금 입력 시 원금·이자 은행과 정확 일치
Deploy / deploy (push) Failing after 11m59s
- 계좌 관리 대출 폼에 '월 상환금(선택)' 입력(원리금균등)
- repayment.js: loanPayment 있으면 이자=잔액×이율, 원금=상환금-이자로 상각
  → 남은 개월수 추정 오차(±1회) 없이 은행 조회액과 일치. 미입력 시 기존 추정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:08:00 +09:00
sb b039069d6c fix(repayment): 원리금균등 월 상환액을 현재 잔액 기준 상각으로 계산
Deploy / deploy (push) Failing after 12m40s
- 원금 음수·잔액 증가 버그 수정: 최초원금 기준 상환액을 쓰면 잔액>원금 등
  데이터 불일치 시 상환액<이자가 되어 원금이 음수가 되고 잔액이 늘어남
- 남은 개월수(n)는 기존대로 잔액 역산, 상환액은 현재 잔액을 n개월 상각 →
  원금 항상 양수, 잔액 0 수렴. 역산 불가 시 이번 달 이자만(안내)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:53:49 +09:00
sb a42715834b fix(repayment): 원리금균등 남은 회차를 잔액으로 역산 + 라벨 정리
Deploy / deploy (push) Failing after 12m41s
- 기존 대출(loanStart 없음)이 남은 회차=전체(60회)로 나오던 문제 수정:
  현재 잔액과 최초 상환액으로 남은 개월수 역산(날짜 정보 불필요)
- '이번 달 상환' → '이번 달 상환 예정금액', 금액 앞 '이번 달' 제거
- '남은 N회' → '남은 회차 N회'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:43:49 +09:00
sb d40c75d3e5 feat(repayment): 대출·카드할부 상환 조회 화면 (유료)
Deploy / deploy (push) Failing after 14m46s
- 사이드바 '대출·할부 상환' 메뉴(프리미엄 잠금), /account/repayments 라우트
- 대출: 상환방식별(원리금균등·원금균등·만기일시) 남은 회차 상각 스케줄
- 카드 할부: 무이자 가정, 진행 중 할부 건별 남은 회차
- 회차별 원금·이자·합계 + 남은 잔액 표기, 이번 달/남은 총액 합계
- 계산은 프런트(utils/repayment.js), 지갑·카드 내역만으로 산출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:31:43 +09:00
ByungCheol fcd3f23a32 fix(loan): 원리금균등 PMT 자동계산 — 납입금액 수동입력 불필요
Deploy / deploy (push) Failing after 11m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:24:42 +09:00
sb 1ee264f2ab chore(i18n): 전체 화면 '고정지출' → '정기결제' 문구 변경
Deploy / deploy (push) Failing after 11m5s
- 사이드바·헤더·홈·업그레이드·데모·정기결제/내역 화면 라벨 통일
- 백업 엑셀 시트명(backup.js)은 기존 백업 복원 호환 위해 '고정지출' 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:20:24 +09:00
sb 98ad7b9e98 feat(wallets): 투자계좌는 상세 이동 비활성 — 내역 없음, 목록에 투자금 표기로 충분
Deploy / deploy (push) Failing after 13m55s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:12:35 +09:00
sb b70742b79c feat(wallets): 순서변경 잠금 모드 + 계좌명 클릭 이동 (오작동 방지)
Deploy / deploy (push) Successful in 33s
- 화면이동 화살표(›) 제거 — 삭제 버튼 옆이라 오탭 위험
- 계좌 클릭 대신 '계좌명' 클릭 시에만 상세 이동(링크 색상 표시)
- 순서변경은 기본 잠금 — '순서변경' 버튼으로 켤 때만 드래그 핸들·재정렬 활성
- 순서변경 모드에선 수정/삭제 버튼 숨겨 오작동 방지, 드래그 낙관적 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:08:53 +09:00
ByungCheol 5beb999535 feat(ui): 태그관리 순서변경 토글, 고정지출 월/년 주기만
Deploy / deploy (push) Failing after 11m46s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:03:14 +09:00
sb 654fc44f48 style(wallets): 투자계좌 목록 금액 앞 '투자금' 라벨 제거
Deploy / deploy (push) Failing after 15m19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:01:11 +09:00
sb 061fb47b97 feat(wallets): 계좌 관리 종류별 그룹 목록 + 계좌 상세 화면 분리
Deploy / deploy (push) Failing after 15m10s
- 탭 제거, 종류별(은행/현금/카드/대출/마이너스/투자) 섹션 목록으로 변경
- 계좌 클릭 시 인라인 드롭다운 대신 별도 상세 화면(/account/wallets/:id)으로 이동
- 상세 화면: 계좌 요약 + 월별 내역(월 네비/합계), 투자는 투자금(원금)만 표기
- 목록에서 투자 계좌는 평가액 제거하고 투자금만 표기
- 드래그 순서변경은 종류별 그룹 단위로 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:56:19 +09:00
sb e95e14378b style(recurring): 무료 한도 안내 문구 축약 — 한 줄에 맞게
Deploy / deploy (push) Failing after 11m8s
'무료 2개까지 등록 완료 · 유료 무제한 →' 로 짧게(기존 두 줄 넘침 해소).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:30:00 +09:00
sb f446fb3f03 docs(upgrade): 플랜 비교 현행화 — 고정지출 무료 2건·유료 무제한, 계좌 종류별 2건
Deploy / deploy (push) Failing after 12m34s
무료: 계좌 종류별 2개·고정지출 2개 자동반영. 유료: 계좌·고정지출 무제한.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:28:56 +09:00
sb 4d8881342a feat(recurring): 고정지출 무료 2건 UI — 유료 잠금 해제·한도 안내·업셀
Deploy / deploy (push) Failing after 12m10s
라우터 requiresPremium 제거, 사이드바 🔒 제거, 자동실행을 전 사용자로,
RecurringView 한도 도달 시 추가 버튼 업셀 다이얼로그+힌트(→/upgrade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:22:45 +09:00
sb c694dd9d04 docs: 화면 밀림 버그 해결 표기 — 원인은 iOS input 자동확대(viewport)
Deploy / deploy (push) Failing after 10m53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:05:37 +09:00
sb ec130e85a6 fix(ios): input 포커스 시 자동확대로 화면 밀림 — viewport maximum-scale 추가
Deploy / deploy (push) Failing after 14m13s
원인: viewport에 maximum-scale 없어 iOS가 16px 미만 input 포커스 시 페이지 자동확대
→ 모든 화면에서 폼 선택 시 가로로 밀려 잘려 보임. maximum-scale=1,user-scalable=no로 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:02:18 +09:00
sb 1169f3e542 docs: 미해결 - 내역모달 가로 오버플로우, 다음 세션 실측/bottom-sheet 대안 기록
Deploy / deploy (push) Failing after 13m56s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:07:34 +09:00
sb 3429ddc235 fix(ui): 내역 모달 min-width:0 — flex 아이템이 내용물만큼 커져 넘치던 근본 원인
Deploy / deploy (push) Failing after 12m42s
backdrop(flex)의 모달 아이템 기본 min-width:auto 가 넓은 그리드만큼 모달을 뷰포트보다
넓혔음. 모달 min-width:0 으로 고정(+칩 min-width:0, overflow-x:hidden 조합)으로 가로 오버플로우 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:03:48 +09:00
sb d84101ecda fix(ui): 계좌 칩 min-width:0 — 그리드 아이템이 안 줄어 넘치던 진짜 원인
Deploy / deploy (push) Failing after 11m18s
칩(그리드 아이템)의 기본 min-width:auto(=글자 전체너비)가 minmax(0,1fr)을 무력화해
긴 계좌명이 그리드를 넓혀 모달이 가로로 넘침. 칩에 min-width:0 추가로 축소+…처리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:55:13 +09:00
sb d7cddba77c fix(ui): 내역 모달 가로 오버플로우 — 계좌 그리드/소분류 입력이 화면 밀어내던 문제
Deploy / deploy (push) Failing after 11m5s
- 모달 overflow-x:hidden(내부 넘침이 페이지를 가로로 밀지 않게)
- ChipSelect min-width:0+box-sizing로 긴 계좌명이 그리드를 넓히지 않게
- cat-input(소분류 추가) flex-wrap로 버튼 잘림 방지
+ 계좌 선택 영역 배경 패널(3열)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:50:26 +09:00
sb e655e79133 style(ui): 계좌 칩 3열 고정 + 박스 배경/테두리 뚜렷하게
Deploy / deploy (push) Failing after 12m6s
모달 배경과 색이 같아 안 보이던 문제 → 회색 틴트+진한 테두리(테마 무관). 한 줄 3개 고정.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:34:24 +09:00
sb 17e7dd53a6 style(ui): 계좌 칩을 균일 박스 그리드로 — 이름 길이 달라도 정렬
Deploy / deploy (push) Failing after 14m4s
ChipSelect를 배경 있는 박스 그리드(auto-fill minmax 120px)로 변경.
자유 줄바꿈으로 들쭉날쭉하던 정렬 해소, 긴 이름은 …+툴팁.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:27:26 +09:00
sb 1f128940f5 fix(recurring): 이체 입금 종류 카드 제외 — AccountView와 일관
Deploy / deploy (push) Failing after 12m30s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:23:26 +09:00
sb 951cbdce34 fix(account): 이체 입금 종류 카드·대출 제외 + 계좌 칩 자동 줄바꿈
Deploy / deploy (push) Failing after 13m59s
- 이체 입금 계좌 종류에서 카드·대출 제외(계좌→카드/대출은 상환)
- 계좌 선택 칩을 고정 2열→자동 줄바꿈(이름 길이/폭에 맞춰 채움, 잘림 없음)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:22:30 +09:00
sb 1b2d6a2c02 fix(account): 출금 계좌 종류 — 카드는 지출일 때만 노출
Deploy / deploy (push) Failing after 15m27s
수입·이체·상환은 카드/대출에서 나가지 않으므로 카드 제외(대출은 이미 제외).
구분 변경 시 허용 안 되는 계좌 종류면 초기화. AccountView·RecurringView 동일 적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:16:03 +09:00
sb 810a5d9380 feat(ui): 공통 ChipSelect + 계좌 선택을 칩(2열)으로
Deploy / deploy (push) Failing after 15m12s
select 대체 ChipSelect 컴포넌트(cols 지원) 신설. 계좌 선택 드롭다운을 칩(한 줄 2개)으로 교체:
- AccountView 출금·입금·상환대상 계좌
- RecurringView 출금·입금 계좌
계좌 종류 뱃지로 이미 걸러지고 무료 한도(종류별 2개)라 칩 수도 적당.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:06:17 +09:00
sb e2d9b8ea37 fix(account): 출금 계좌 종류 뱃지에서 대출 제외
Deploy / deploy (push) Successful in 36s
대출은 지출·이체의 출처가 아니라 상환 대상 → 계좌 종류(출금) 선택지에서 대출 숨김.
이체 입금 종류·상환 대상(대출/카드)은 그대로 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:51:46 +09:00
sb cdf94b75cb fix(account): 빠른입력 대출상환 — 금리 있으면 이자/원금 자동분리
Deploy / deploy (push) Failing after 15m15s
금액을 principal 이 아니라 납입금액(loanPaymentAmount)에 넣어 기존 자동분리 로직이 작동하게 함
(toWalletId 리셋 감시자 뒤 nextTick). 금리 미설정/카드는 전액 원금.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:36:15 +09:00
sb e3ab2f9f3c fix(account): 빠른입력 — 계좌→대출/카드 이체는 상환(REPAYMENT)으로 재분류
Deploy / deploy (push) Failing after 11m2s
AI가 TRANSFER로 준 것을, 받는 계좌가 대출/카드면 상환으로 바꿔 폼을 상환모드로 염
(출금=walletId·대상=toWalletId·원금=금액). 이자 있으면 사용자가 분리 입력.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:25:29 +09:00
sb 769aa38b52 feat(account): 빠른입력 이체 폼 반영 — 출금/입금 계좌 자동 채움
Deploy / deploy (push) Failing after 13m5s
applyParsed 가 TRANSFER 처리: type=이체, 분류 비움, wallet→출금(walletId)/toWallet→입금(toWalletId) 매칭.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:16:52 +09:00
sb d5b4c46163 feat(stats): 예상 지출 10일부터 노출 — 초반 표본부족 튐 방지
Deploy / deploy (push) Failing after 13m52s
경과일 < 10 이면 값 대신 '10일부터 표시돼요' 안내. 초반 소표본+일회성으로 과대 추정되던 문제 완화.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 02:17:38 +09:00
sb 5b46ba93f3 fix(stats): 예상지출 — 비정기 큰 지출(대출상환 등)을 모두 일평균에서 제외
Deploy / deploy (push) Failing after 14m58s
가장 큰 하루 1건만 제외하던 것을, 중앙값 3배(또는 10만원) 초과 '모든' 날 제외로 변경.
대출상환·다건 일회성이 일평균에 남아 남은 날에 곱해져 예상지출이 과다 추정되던 문제 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 02:01:33 +09:00
sb a52ac6532c docs: 백로그에 포인트 상점(AI 사용량 종량제·계좌 슬롯) 추가
Deploy / deploy (push) Failing after 12m15s
AI 텍스트 월5회/OCR 월3회 무료 + 포인트 추가충전(300P/500P), 계좌슬롯 500P·최대5·영구.
설계·확정필요·견적(~1~1.5일) 기록.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:54:17 +09:00
sb 253e88e0fa feat(wallet): 무료 회원 종류별 2개 한도 안내 — 추가 버튼 비활성·힌트
Deploy / deploy (push) Failing after 14m9s
한도 도달 시 추가 버튼 비활성 + '무료 종류별 2개·유료 무제한' 안내, openCreate 가드.
백엔드 400 메시지는 폼 에러로 폴백 노출.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:40:41 +09:00
sb 296ae594f8 fix(layout): 앱 셸 고정 높이 — 페이지 전체 스크롤 제거 + 상·하단 고정
Deploy / deploy (push) Failing after 12m26s
.layout 을 100dvh 고정 + overflow hidden, .layout-body 에 min-height:0 로
본문만 세로 스크롤(가로 숨김). 기기별 뷰포트·안전영역 차로 생기던 불필요한
상하좌우 스크롤과 하단 내비 밀림 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:19:04 +09:00
ByungCheol ac20174378 build(android): versionCode 4, versionName 1.0.3 — Play Store v4 AAB 빌드
Deploy / deploy (push) Successful in 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 17:48:15 +09:00
sb 87c7695cc3 docs: 오늘 작업 현행화 — AI 편의기능·홈 넛지·통계 통합·개근 보너스·iOS 수정
Deploy / deploy (push) Failing after 13m21s
- LAUNCH-PROGRESS.md: 2026-07-05 추가분 반영
- README.md 주요 기능: AI 보조·활동 포인트·통계 현행화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:28:11 +09:00
sb 9d1ae0e402 feat(home): 인사말을 '오늘 기록 넛지'로 — 습관 유도
Deploy / deploy (push) Failing after 10m46s
'안녕하세요 OO님' 자리를 오늘 기록 상태로 대체:
- 오늘 기록 없음 → 'OO님, 오늘은 아직 기록이 없어요' + 지금 기록하기 CTA
- 오늘 기록 있음 → '오늘 N건 기록했어요' / 연속 2일+ → 'N일 연속 기록 중 🔥'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:40:45 +09:00
ByungCheol fb4accdca6 docs: SQL_INIT_MODE=always 적용 완료 — 안내 문구 제거
Deploy / deploy (push) Failing after 13m55s
2026-07-05 16:27:33 +09:00
ByungCheol 03e11cc79c docs: LAUNCH-PROGRESS 충돌 해결 — AI 기능 반영, BudgetOnetime 완료 표기
Deploy / deploy (push) Failing after 11m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 16:24:33 +09:00
sb d1dd19d5a3 docs+policy: AI(Anthropic) 데이터 처리 고지 + 런치 문서 현행화
Deploy / deploy (push) Failing after 14m28s
- 개인정보처리방침(terms.js §2·§3): 처리자 Anthropic(Claude) 명시, 전송 데이터 범위·학습 미사용·서버 키보관 고지. OCR은 AI 보조/폴백으로 갱신. 적용일 2026-07-05
- play-store-listing.md 데이터안전: AI 편의 기능 처리 위탁 명시(공유 아님)
- LAUNCH-PROGRESS.md: AI 기능 완료 반영, 가계부 오류 해결 표시, v3/재개지점 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:15:45 +09:00
sb f3f11fa15b fix(ui): 사이드바 드로어 상단 safe-area 여백 — iOS 상태바 겹침 해결
Deploy / deploy (push) Failing after 11m23s
드로어 최상단(로고/제목)이 iOS 상태바에 가리던 문제. app-sidebar 에 padding-top: env(safe-area-inset-top) 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:00:08 +09:00
sb 4c325544a6 fix(account): 모달 열 때 클립보드 자동읽기 제거 — iOS '붙여넣기' 토스트 방지
Deploy / deploy (push) Failing after 13m7s
내역 추가·빠른입력 모달이 열릴 때 navigator.clipboard.readText() 호출로
iOS 시스템 붙여넣기 토스트가 매번 떴음. 자동 읽기/배지 감지 제거(수동 붙여넣기는 유지).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:53:24 +09:00
sb 0090c31294 fix(stats): 예상 지출을 결정적 계산으로 — 매 접속 동일값
Deploy / deploy (push) Failing after 11m2s
일별 지출에서 일회성 큰 지출일(중앙값 3배 초과)을 하루평균 산정에서 제외 →
실제 지출 + 남은 날 × 평소 하루 지출. AI 비결정 값(40만~500만 변동) 문제 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:43:47 +09:00
sb 59a17f9087 feat(stats): AI 코멘트 로딩 인디케이터 바 + 예상지출 '계산 중' 표시
Deploy / deploy (push) Failing after 12m6s
코멘트 생성 지연 동안 애니메이션 진행바와 예상지출 계산 중 상태 노출.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:34:23 +09:00
sb e9e397434f feat(stats): 예상 지출을 AI 추정으로 교체 — 일회성 큰 지출 왜곡 해결
Deploy / deploy (push) Failing after 10m53s
선형 계산(expenseForecast) 제거, AI 코멘트의 forecast/근거 표시. 경과일·총일수 전달.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:29:16 +09:00
sb 9f9fc14c64 refactor(stats): 예산·수입 대비 지출을 '이번 달 현황' 단일 패널로 통합
Deploy / deploy (push) Failing after 11m32s
두 패널을 하나로 합쳐 지출 중복 표기 제거·화면 밀도 완화(도넛+수입/지출 좌우 배치, 모바일 세로).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:19:59 +09:00
sb a9d4ea8410 feat(account): 통계 AI 코멘트 카드에 절약 가이드 섹션 추가
Deploy / deploy (push) Failing after 11m37s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:13:19 +09:00
sb 7affe5c318 feat(account): 통계 화면에 AI 재무 코멘트 카드
Deploy / deploy (push) Failing after 14m16s
집계만 /account/ai-comment 로 보내 코멘트 수신, 상단에 카드 표시(로딩·빈값 처리).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:06:56 +09:00
ByungCheol 5660451f04 docs: LAUNCH-PROGRESS 백로그 정리 (2026-07-05)
Deploy / deploy (push) Failing after 15m25s
완료 항목 v2 이후 추가 섹션으로 이동:
- 게시판 포인트 정책 / 가계부 포인트 / 스팸 자동 블라인드 / 관리자 신고 목록 고도화 / 외화 환율 갱신 시각
백로그 3항목 제거(완료), 다음 세션 재개 지점 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 15:01:04 +09:00
ByungCheol c069e70de4 feat(fx): 환율 갱신 시각 표시 및 자동 업데이트 레이블
Deploy / deploy (push) Failing after 13m33s
- 환율 자동조회 후 '방금 업데이트 / N분 전 / N시간 전' 표시
- 30초 간격 ticker로 레이블 자동 갱신
- 폼 열기/통화 변경 시 rateUpdatedAt 초기화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:52:02 +09:00
sb e23a694675 fix(account): 영수증 AI 힌트를 단일 필드로 전송(멀티파트 파트수 제한 회피)
Deploy / deploy (push) Failing after 13m37s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:46:44 +09:00
sb 7e13a42848 feat(account): 영수증 사진 AI 구조화 — 비전 우선, OCR 폴백
Deploy / deploy (push) Failing after 14m23s
영수증 선택 시 Claude Vision(/receipt-ai)으로 금액·상호·날짜·분류·계좌 자동 채움.
AI 실패/미인식 시 기존 Google Vision OCR+규칙파서로 폴백. 결과 배너에 ·분류 표시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:22:07 +09:00
sb 6a4d01b6e1 feat(account): 자연어 빠른입력 완성 — 분류·계좌 자동추천 + 내역화면 진입점
Deploy / deploy (push) Failing after 11m25s
- applyParsed의 미선언 categoryMajor 참조 버그 수정(응답 처리 실패 원인)
- 빠른입력 모달 버튼 텍스트화 + 안전영역 하단 고정(가림 해결)
- 클립보드 자동입력을 거래성 텍스트일 때만(로컬 판단, 불필요 AI 호출 제거)
- AI 응답의 분류·결제수단을 폼에 자동 매칭(사용자 실제 목록 기준)
- 내역 화면에 ' 빠른 입력' 텍스트 바 진입점 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:10:05 +09:00
ByungCheol 9994456f30 feat(admin): 스팸 감지 항목 관리자 신고 목록 표시
Deploy / deploy (push) Failing after 11m46s
- 스팸 감지 항목에 🤖 스팸감지 뱃지 표시 (🚩 신고 수 대신)
- blockReason(블라인드 사유) 카드에 표시
- 스팸 항목에는 신고 무시 버튼 숨김
- 안내 문구에 스팸 자동 블라인드 정책 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 13:54:41 +09:00
sb 066ada961a feat(ai): 빠른 입력 버튼 라벨 '문자·푸시'→'빠른 입력' (자연어 입력 발견성)
Deploy / deploy (push) Failing after 10m50s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:50:39 +09:00
sb a561eccef8 feat(ai): 빠른 입력 모달 — 자연어 입력 안내 (백엔드 AI 파싱 연계)
Deploy / deploy (push) Failing after 11m36s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:24:14 +09:00
sb 902b213f60 ci: setup 캐시 제거 — 'Save cache' 후처리 hang 회피
Deploy / deploy (push) Failing after 15m35s
Gitea 캐시 서버 미연결 시 setup-node cache:npm 의 저장 후처리가 수 분 hang →
'job succeeded 인데 계속 실행중 → 타임아웃 가짜 실패' 유발. 캐시 제거로 즉시 종료.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:55:57 +09:00
sb 42406b4c75 fix(ui): 내역 추가 모달 닫기(×) 버튼이 상태바에 가려 클릭 안 되던 문제
Deploy / deploy (push) Failing after 14m18s
전체화면 entry 모달의 .close 에 env(safe-area-inset-top) 누락 → 상태바/노치 아래로 내림.
중앙 모달(확인/붙여넣기)에는 미적용(닫기가 다이얼로그 기준이라 불필요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:42:37 +09:00
sb 16e8973532 feat(ios): 구글 로그인 iOS 설정 — GIDClientID + URL Scheme(역순 클라이언트 ID)
Info.plist 에 iOS OAuth 클라이언트 ID 와 리다이렉트 URL 스킴 추가.
plugin initialize 의 web 클라이언트 ID 는 그대로(백엔드 aud 검증).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:24:15 +09:00
sb 0d7e9ba4f5 docs: iOS 가이드(IOS.md) — 빌드·실기기 실행·Apple 로그인·safe-area·배포 정리
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:48:20 +09:00
sb 1525460149 fix(ui): 헤더 로그인 버튼과 하단 구분선 겹침 — 세로 패딩 추가
Deploy / deploy (push) Failing after 14m23s
- .app-header 및 모바일 미디어쿼리(≤768px) 둘 다 세로 패딩(0.5rem) 적용
  (미디어쿼리가 padding:0 1rem 으로 덮어써 앱에서 효과 없던 문제 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:26:54 +09:00
sb cbb3d47bfa fix(ui): iOS safe-area 겹침 수정 — 헤더·하단 내비 콘텐츠 눌림 해결
- border-box 전역에서 고정 height 가 safe-area 패딩을 포함해 콘텐츠 행이 눌리던 문제
- AppBottomNav: height = calc(56px + safe-area-inset-bottom) → 아이콘 행 온전한 56px + 홈인디케이터 분리
- AppHeader: height → min-height → layout-top 상단 safe-area 패딩만큼 헤더가 커지도록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:12:50 +09:00
sb 1cbf47ff87 feat(auth): iOS 애플 로그인 버튼 — Sign in with Apple(App Store 4.8 대응)
- @capacitor-community/apple-sign-in + LoginModal Apple 버튼(iOS 전용, HIG 검은 버튼)
- appleAuth 네이티브 래퍼 + authApi/auth store appleLogin
- SPM capacitor-swift-pm 8.x 로 패치(patch-package) — @capawesome 구글 플러그인과 iOS 공존
- App.entitlements(applesignin) 준비 — Xcode 'Sign in with Apple' Capability 추가 시 사용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:01:43 +09:00
sb a7e5bf9c0f fix(ui): 하단 내비 상단 그림자 추가 — 콘텐츠와 시각적 분리(iOS 탭바 스타일)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:32:29 +09:00
sb 9de1949da8 feat(ios): Capacitor iOS 플랫폼 추가 — 시뮬레이터 실행 확인
- @capacitor/ios 추가, cap add ios (SPM, 플러그인 7종)
- Info.plist 카메라·사진 보관함 권한 문구
- 브랜드 앱 아이콘 생성, 번들 ID kr.sblog.slimbudget (안드와 동일)
- iPhone 17 시뮬레이터(iOS 26.5) 빌드·구동 확인

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:23:28 +09:00
sb 7861eef05b feat(admin): 신고 관리 화면 — 신고된 글/댓글 목록·블라인드·신고 무시
Deploy / deploy (push) Failing after 14m51s
- /admin/reports 라우트 + 사이드바 메뉴 + 헤더 타이틀
- boardApi.reports()/dismissReport() 추가(기존 block/unblock 재사용)
- 글 보기·블라인드(사유)·해제·신고 무시 액션

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:10:06 +09:00
sb a261dd2ca2 docs: README 실제 문서화 + FRONTEND.md 현행화(브랜딩·플랫폼·화면·투자)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:10:06 +09:00
ByungCheol ea9fbc359b feat(ui): 입력 모달 전체화면 슬라이드업 + 셀렉트→뱃지 전면 전환
Deploy / deploy (push) Failing after 12m1s
- AppModal.vue: 전체화면 슬라이드업 공통 모달 (Teleport+Transition)
- CategoryPicker.vue: 대/소분류 아코디언 칩 공통 컴포넌트
  admin 모드(일반: 행 단위+수정/삭제, 순서변경: flat+SortableJS)
- AccountView: 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
- RecurringView: 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
- AccountWalletView: 카드종류·상환방식 셀렉트→뱃지
- BudgetView: 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- CategoryView: 리스트 UI → CategoryPicker 칩 그리드 관리 모드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 01:24:15 +09:00
ByungCheol d364b52c73 fix(account): wallets ref TDZ 오류 수정 — computed 선언 전 초기화되도록 위로 이동
Deploy / deploy (push) Failing after 14m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:56:14 +09:00
ByungCheol 0b64f4f8cd Merge branch 'dev'
Deploy / deploy (push) Failing after 11m10s
2026-07-02 01:50:09 +09:00
ByungCheol c0e5421c33 fix(account): AccountView.vue 첫 줄 잘못된 '1' 문자 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:43:01 +09:00
ByungCheol 282fb7476d Merge branch 'dev'
Deploy / deploy (push) Failing after 12m58s
2026-07-02 00:31:53 +09:00
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 31cb4b7198 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m36s
2026-07-02 00:02:13 +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 f15820f204 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m12s
2026-07-01 00:41:46 +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 7965706167 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m16s
2026-07-01 00:33:41 +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 8d1567cb4b Merge branch 'dev'
Deploy / deploy (push) Failing after 11m7s
2026-07-01 00:20:17 +09:00
ByungCheol b2f3b156b2 fix: 분류 소분류 영역 배경색 구분 — 회색 박스로 대분류와 시각적 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:19:27 +09:00
ByungCheol 1996c3355e Merge branch 'dev'
Deploy / deploy (push) Failing after 11m28s
2026-07-01 00:14:58 +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 37b4a9f4cf Merge branch 'dev'
Deploy / deploy (push) Successful in 40s
2026-07-01 00:07:29 +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 daf6b840a9 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m24s
2026-06-30 22:45:34 +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 27d405b154 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m43s
2026-06-30 22:35:52 +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 8604fb5030 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m54s
2026-06-30 22:14:05 +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 98172b3c56 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m43s
2026-06-30 22:09:15 +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 8d659ea956 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m53s
2026-06-30 22:06:04 +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 46a8cb5515 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m53s
2026-06-30 21:56:06 +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 4c846c5bee Merge branch 'dev'
Deploy / deploy (push) Failing after 10m46s
2026-06-30 21:44:14 +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 e2d3be29da Merge branch 'dev'
Deploy / deploy (push) Failing after 14m4s
2026-06-30 21:37:26 +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 20f67bbc76 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m42s
2026-06-29 21:36:18 +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 8e7955b5d7 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m6s
2026-06-29 20:25:52 +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 2329da1090 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m28s
2026-06-29 09:33:35 +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 60794733a1 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m0s
2026-06-29 09:25:59 +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 b39190baa2 Merge branch 'dev'
Deploy / deploy (push) Successful in 36s
2026-06-29 09:09:03 +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 48c3664136 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m22s
2026-06-29 08:45:40 +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 091b42eb81 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m40s
2026-06-29 08:33:22 +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 d5be3c4fcd Merge branch 'dev'
Deploy / deploy (push) Failing after 13m54s
2026-06-29 08:26:05 +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
89 changed files with 8593 additions and 2046 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install
run: npm ci
+58 -31
View File
@@ -1,44 +1,71 @@
# sb_pt
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
This template should help get you started developing with Vue 3 in Vite.
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron) 3개 플랫폼을 하나의 코드베이스로 빌드합니다.
## Recommended IDE Setup
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
- 운영: [app.sblog.kr](https://app.sblog.kr) · 안내/정책 페이지 [www.sblog.kr](https://www.sblog.kr)
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
## 기술 스택
- **Vue 3** (`<script setup>` Composition API) + **Vite**
- **Pinia** (상태관리), **Vue Router** (라우팅 + 인증 가드)
- **Axios** (공통 인스턴스 + 토큰/401 인터셉터)
- **Toast UI Editor** + Prism.js (게시판 마크다운 작성/뷰어)
- **Capacitor** (안드로이드 하이브리드 앱), **Electron** (Windows 데스크톱)
- **Vitest** + `@vue/test-utils` (테스트)
## 빠른 시작
```sh
npm install
npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예외)
```
- Node: `^20.19.0 || >=22.12.0`
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`, Nginx 가 백엔드 8080 으로 프록시)
### Compile and Hot-Reload for Development
## 주요 스크립트
| 명령 | 설명 |
|---|---|
| `npm run dev` | 개발 서버 (HMR) |
| `npm run build` | `dist/` 정적 빌드 (웹 배포용) |
| `npm run build:app` | Capacitor 모드 빌드 (`--mode capacitor`, 앱 전용 env) |
| `npm run cap:sync` | 앱 빌드 + `cap sync` (안드로이드 동기화) |
| `npm run cap:android` | Android Studio 열기 |
| `npm run electron:dev` | Electron 데스크톱 실행 |
| `npm run electron:build` | Windows 설치파일(`DonDwaeji-Setup.exe`) 빌드 |
| `npm test` | Vitest 실행 |
| `npm run lint` | oxlint + eslint (`--fix`) |
```sh
npm run dev
```
## 플랫폼별 동작
- **웹(PC·모바일)**: 안내 페이지만 노출(앱 다운로드 유도). 실제 이용은 앱에서. (`npm run dev` 는 예외로 전체 앱 표시)
- **안드로이드**: Capacitor WebView 패키징. 빌드 전 `.env.capacitor` 의 백엔드 절대 URL 설정 필수 → [docs/ANDROID.md](docs/ANDROID.md)
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
### Compile and Minify for Production
## 인증
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage` 에 저장, `http.js` 인터셉터가 `Authorization` 헤더에 주입
- **구글 로그인**(웹/PC=GIS, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(아이디/비번 폼은 `features.js` 플래그로 숨김)
- 401 응답 시 토큰 정리 후 로그인 모달, 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
```sh
npm run build
```
## 주요 기능
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
- **AI 보조** — 자연어 한 줄 입력("어제 스타벅스 5천원"), 영수증 사진 구조화, 분류·계좌 자동 추천, 통계 화면 재무 코멘트·절약 가이드 (Claude Haiku, 집계만 전송·저장은 사용자 확인 후)
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
- **통계** — '이번 달 현황'(예산·수입 대비 통합), 분류별 분석, 순자산 추이, 월말 예상 지출(일회성 큰 지출 제외 결정적 계산), 지난달 대비
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
- **활동 포인트** — 가계부·게시판 활동 적립, 한 달 개근(모든 날 기록) 랜덤 보너스
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
- **테마** — 시스템/라이트/다크 모드
### Lint with [ESLint](https://eslint.org/)
## 문서
| 문서 | 내용 |
|---|---|
| [docs/FRONTEND.md](docs/FRONTEND.md) | 아키텍처·디렉터리 구조·화면 명세 |
| [docs/release-2026-06-06.md](docs/release-2026-06-06.md) | 누적 변경 이력 (릴리스 노트) |
| [docs/ANDROID.md](docs/ANDROID.md) | 안드로이드(Capacitor) 빌드·배포 |
| [docs/plan-membership-tiers.md](docs/plan-membership-tiers.md) | 무료/유료 티어 설계 (미구현) |
| [deploy/README.md](deploy/README.md) | 프론트 배포(Nginx + Gitea Actions) |
| [brand/LAUNCH-PROGRESS.md](brand/LAUNCH-PROGRESS.md) | Play 스토어 출시 진행 상황·로드맵 |
```sh
npm run lint
```
## 배포
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(Nginx 업로드·reload) 실행
- **테스트 게이트**: CI·배포 워크플로 모두 테스트 실패 시 배포 중단
- 상세: [deploy/README.md](deploy/README.md)
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId "kr.sblog.slimbudget"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 6
versionName "1.0.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+198
View File
@@ -0,0 +1,198 @@
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
> 최종 업데이트: 2026-07-11. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 관련 문서: [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-07-05)
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
-**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
- ✅ Google Search Console sblog.kr 도메인 소유권 인증 완료 (DNS TXT)
-**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
-**v4 AAB 빌드 완료** (versionCode 4, 1.0.3) → 비공개 트랙 업로드 대기
- ⏳ 개인사업자 계정 전환 → DUNS 신청 완료(D&B 발급 대기) → 발급 후 Play Console에서 마저 진행
- Play Console 웹사이트 인증(www.sblog.kr) 완료
### 🍎 iOS(App Store) 트랙 — 2026-07-11
-**애플 개발자 등록 완료** (Team ID `G9PDM2C4L6`)
-**애플 로그인(Sign in with Apple) 실기기 동작 확인** — App Store 4.8 요건 충족
-**iOS 번들ID `kr.sblog.slimbudget.app`** (기존 `kr.sblog.slimbudget`는 타 계정 선점 → iOS 전용 새 ID, 안드로이드는 그대로)
- App ID + Sign in with Apple/Push capability, `CODE_SIGN_ENTITLEMENTS` 연결, `ITSAppUsesNonExemptEncryption=false`
-**App Store Connect 앱 생성 + v1.0 (build 1) 심사 제출 완료** — 연령 15+, App Privacy(이메일·사용자콘텐츠·사용자ID / 추적없음 / 앱기능), 무료, 콘텐츠권한 없음, 스크린샷(6.5" 1242×2688 iPhone + iPad 13")
- 로그인은 구글/애플 소셜 전용 → 심사 메모에 **둘러보기(데모)** 안내
-**심사 결과 대기** (승인 시 출시 / 거부 시 Resolution Center 대응)
---
## 📋 남은 로드맵 (프로덕션까지)
1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
3.**v4 AAB 비공개 트랙 업로드** — 빌드 완료(versionCode 4, 1.0.3), Play Console 업로드 후 검토 제출 (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`는 환산 원화라 통계·예산 무변경
- **외화 환율 갱신 시각** — 환율 자동조회 후 '방금 / N분 전 / N시간 전' 레이블(30초 ticker), FxController `updatedAt` 응답 포함
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
- **UI 전면 개편 (2026-07-04)** — 모든 입력 모달 전체화면 슬라이드업(AppModal) 적용:
- 공통 `CategoryPicker.vue` 컴포넌트 — 대분류/소분류 아코디언 칩 + 인라인 추가 (가계부·고정지출·예산 공통)
- `AccountView` 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
- `RecurringView` 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
- `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지
- `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- `CategoryView` 리스트 UI → **칩 그리드 관리 모드** (CategoryPicker admin prop): ✏ 이름수정·✕ 삭제·⠿ 순서변경(토글 후 드래그)
- **게시판 포인트 정책** — 작성 10P(일 5회·50P 한도), 스팸 키워드 포함 시 미지급; 추천 +5P(작성자·일 50P)/비추 -1P; 남글추천 +1P(투표자·일 10P)/취소 -1P
- **가계부 작성 포인트** — 건당 10P, 일 5회(50P) 한도
- **스팸 키워드 자동 블라인드** — 14개 키워드 포함 시 글/댓글 즉시 블라인드, 포인트 미지급
- **관리자 신고글 목록 고도화** — 신고 누적 + 스팸 감지 통합; 🤖 스팸감지 뱃지·blockReason 표시; 스팸 항목 신고 무시 버튼 숨김
**2026-07-09~11 추가 (웹·서버 배포 완료 / iOS는 재빌드 시 반영)**
- **애플 로그인(Sign in with Apple)** — 프런트(`native/appleAuth`·LoginModal)·백엔드(`/auth/apple`, 애플 JWKS 검증·aud=번들ID)·DB(`member.apple_id`)·iOS entitlements 전 구간. iOS 번들ID `kr.sblog.slimbudget.app`로 변경, 서버 `apple-client-id` 정합. 실기기 로그인 확인
- **입력 UI 모달 → 페이지 전환 + 바텀시트** (톤 통일)
- **계좌**(`WalletFormView`)·**정기결제**(`RecurringFormView`) → 별도 라우트 페이지, 생성 시 계좌 종류 선택 복원
- **내역·예산** → 콘텐츠 영역 인라인 페이지(`?entry`/`?budget` 라우트 바인딩, 뒤로가기로 닫힘) — 핵심 로직(AI·OCR·상환 등) 이관 없이 프레젠테이션만 변경(회귀 위험 최소화)
- 선택 항목 **바텀시트**: `BottomSheet`·`SheetSelect`·**`AccountPicker`(종류→계좌 아코디언)** 공통 컴포넌트. 계좌·분류·검색 필터에 적용(분류는 CategoryPicker `hideAdd`)
- **화면 헤더 뒤로가기 버튼 제거** → 하단 네비 뒤로가기로 유도
- **계좌 관리 개편** — 탭 제거→종류별 그룹 목록, 계좌명 클릭 시 **상세 화면**(`AccountWalletDetailView`), 순서변경 잠금 토글, 투자계좌는 투자금만
- **대출·카드할부 상환 조회(유료)** `RepaymentScheduleView`+`utils/repayment.js` — 상환방식별 상각+카드할부(무이자), 회차별 원금·이자·합계·잔액. **대출 월 상환금(loanPayment) 필드**로 은행 조회액과 정확 일치. 이자율 소수점 3자리. 홈 **'이번 달 상환 예정' 카드(유료)**
- **예산 단순화** — '월 예산'만 등록, 고정/비고정·일/주/년·**1회성 예산 제거**
- **레이아웃** — 하단 고정 내비에 가리던 문제 수정(본문 `padding-bottom`)
- **정기결제 등록 자동세팅** — 원리금균등은 저장된 월 상환금 우선
- **연락처 이메일** → `dondwaeji.app@gmail.com` (약관·계정삭제·스토어 문의)
**2026-07-07 추가 (웹·서버 배포 완료)**
- **정기결제 리네이밍** — 전체 화면 '고정지출' → '정기결제' (백업 엑셀 시트명은 기존 백업 복원 호환 위해 유지)
- **정기결제 주기 월/년만** — DAILY·WEEKLY 제거, 요일선택 UI 제거 (`RecurringView`)
- **계좌 관리 화면 개편** — 탭 제거 → **종류별 그룹 목록**, 계좌명 클릭 시 **상세 화면 분리**(`/account/wallets/:id` `AccountWalletDetailView`), 순서변경 **잠금 토글**, 투자계좌는 투자금만 표기·상세 비활성
- **태그관리 순서변경 토글** — 기본 비활성, '순서변경' 버튼 클릭 시 드래그 활성 (`AccountTagView`)
- **대출·카드할부 상환 조회 (유료)** — `RepaymentScheduleView.vue` + `utils/repayment.js`. 대출 상환방식별 상각(원리금균등·원금균등·만기일시) + 카드 할부(무이자) **남은 스케줄**, 회차별 **원금·이자·합계·남은잔액**, 이번 달/남은 총액. 사이드바 유료 메뉴
- **대출 월 상환금(loanPayment) 필드** — 백엔드 컬럼(`wallet.loan_payment`)+DTO/매퍼, 계좌 폼 선택 입력. 원리금균등을 **은행 조회액과 정확히 일치**(이자=잔액×이율, 원금=월상환금−이자), **마지막 회차 잔여 반올림 정산**. 미입력 시 잔액·개월수로 PMT 추정
- **대출 이자율 소수점 3자리** 입력(step 0.001, `loan_rate` DECIMAL(7,4))
- **가계부 대출 상환 자동세팅** — 원리금균등 선택 시 저장된 **월 상환금 우선** 반영(옛 PMT 추정 대체), 마지막 회차 초과상환 방지 (`AccountView`)
- **홈 대시보드 '이번 달 상환 예정' 카드 (유료)** — 현재일 기준 대출·카드할부 상환 예정 합계, **가계부에 상환 기록 있으면 제외**, 모두 기록 시 '상환 완료' 표시
- **AI 교통 절약 팁 제외** — 버스·지하철·대중교통 분류는 정기권 최저비용 사용 중으로 tips에서 제외 (`AiEntryParser`)
**2026-07-05 추가 (웹 배포됨, v3 빌드 대기)**
- **AI 편의 기능** — 자연어 입력·영수증 비전·분류/계좌 추천·재무 코멘트·절약 가이드 (상세는 아래 🤖 섹션)
- **홈 대시보드 오늘 기록 넛지** — '안녕하세요 OO님' → 오늘 기록 상태로 대체(없으면 '지금 기록하기' CTA / 있으면 'N건'·연속 2일+ 'N일 연속 🔥')
- **통계 화면 정리** — '예산 대비'+'수입 대비' 두 패널 → '이번 달 현황' 단일 패널 통합; 예상 지출은 **결정적 계산**(일회성 큰 지출 제외, 매 접속 동일값); AI 코멘트 로딩 인디케이터
- **한 달 개근 보너스 포인트** — 현재 월 모든 날 기록 시 **랜덤 1~100P**(월 1회, 전월 소급 제외, 화면 안내 없음, `MONTH_COMPLETE_yyyyMM`로 중복 차단)
- **iOS 안전영역·클립보드 수정** — 사이드바 상단 상태바 겹침 해결; 모달 열 때 클립보드 자동읽기 제거(iOS '붙여넣기' 토스트 방지). 음성 입력=iOS 키보드 받아쓰기(실기기 확인)
---
## 📌 남은 백로그 (출시 후 업데이트)
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
4.**포인트 상점** (2026-07-10 구현 완료)
- **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
- **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
- FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
- PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
- 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
-**① 자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움. 내역 화면 '✨ 빠른 입력' 바 진입점. (음성=iOS 키보드 받아쓰기, 실기기 확인)
-**② 영수증 사진 → AI 구조화** — Claude Vision이 금액·상호·날짜·분류·계좌 추출(`/ocr/receipt-ai`), 실패 시 Google Vision 폴백.
-**③ 분류·계좌 자동 추천** — 사용자 실제 분류·계좌 목록 중에서 선택(할루시네이션 방지 검증).
-**AI 재무 코멘트 + 절약 가이드** — 통계 화면, **집계 수치만** 전송(거래 원문 미전송), 정중체. 예상 지출은 비결정성 때문에 **결정적 계산**(일회성 큰 지출 제외)으로 분리.
-**개인정보처리방침·데이터안전 갱신** — 처리자 Anthropic(Claude) 명시, 전송 데이터 범위·학습 미사용 고지 (terms.js §2·§3, play-store-listing.md 데이터안전).
- 원칙 준수: AI는 폼 prefill·참고 코멘트만, **저장은 사용자 확인 후**.
---
## 🔨 빌드 상태
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
- **v3** (versionCode 3): 위 'v2 이후 추가' + **AI 기능(자연어·영수증·분류/계좌·재무코멘트)** 전부 반영 — 빌드 후 비공개 트랙 업로드 (14일 카운트 리셋 안 됨)
- ⚠️ 빌드 전 서버 `.env``ANTHROPIC_API_KEY`(AI) 확인
- ✅ 가계부 내역 오류 해결됨 (2026-07-05)
- ✅ 1회성 예산(BudgetOnetime) 백엔드 배포 완료 (2026-07-05)
---
## 다음 세션 재개 지점
0.**[진행중] iOS App Store 심사 결과 대기** — v1.0 제출 완료. 승인 시 출시(자동/수동), 거부 시 Resolution Center 대응(주로 데모 접근 안내)
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
2. **v5 AAB 비공개 트랙 업로드** — 이번 작업분(애플로그인·페이지개편·상환조회 등) 반영해 AAB 재빌드·업로드
3. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환
4. **수익화(출시 후)** — 구독(iOS StoreKit 미구현 → 결제코드+ASC상품+영수증검증 필요) / 광고(광고SDK+ATT+App Privacy 갱신). 둘 다 무료 출시 후 업데이트로
5. (선택) **음성 전용 🎤 버튼**`@capacitor-community/speech-recognition`
6. **CI 러너 안정화** — Gitea act_runner 멈춤(재부팅 후 대기)로 배포 지연된 적 있음 → 자동재시작/헬스체크
+136
View File
@@ -0,0 +1,136 @@
# Play Console 등록 자료 — 돈돼지 가계부
> 첫 출시(무료 앱, 인앱결제·광고 모두 비활성화) 기준. Play Console 각 항목에 그대로 붙여넣기.
> 광고(카카오 애드핏)를 껐으므로 광고 식별자 수집/제3자 공유가 없어 데이터 안전이 단순함.
---
## 1. 스토어 등록정보 (Main store listing)
### 앱 이름 (30자 이내)
```
돈돼지 가계부
```
### 간단한 설명 / Short description (80자 이내)
```
가계부·자산·예산을 슬림하게. 고정지출 자동, 통계 차트, 영수증 인식까지.
```
### 자세한 설명 / Full description (4000자 이내)
```
🐷 돈돼지 가계부 — 슬림하게 관리하는 가계부 · 자산 · 예산
복잡한 가계부는 그만! 수입·지출을 빠르게 기록하고, 자산과 예산을 한눈에 관리하세요.
군더더기 없는 깔끔한 화면으로 매일 쓰기 부담 없는 가계부입니다.
▪ 간편한 가계부
· 수입·지출·이체를 몇 번의 터치로 기록
· 자주 쓰는 내역을 칩으로 등록해 1초 입력
· 월별 수입/지출/잔액 요약과 일별 내역 정리
▪ 자산 · 부채 관리
· 은행·현금·신용/체크카드·대출·투자 계좌를 한 곳에서
· 총자산 · 총부채 · 순자산을 자동 집계
▪ 고정 지출 자동 반영 (프리미엄)
· 월세·구독료·급여 등 반복 거래를 등록해두면 주기에 맞춰 자동 생성
· 실제 결제만 '확인'으로 확정 — 빠뜨림 없는 가계부
▪ 통계와 예산 (프리미엄)
· 분류별 지출, 수입 대비 지출, 지난달 대비, 월말 예상까지 차트로 분석
· 카테고리별 예산을 정하고 예산 대비 지출을 한눈에
▪ 영수증 인식 (프리미엄)
· 영수증을 촬영하면 금액·내역을 자동 인식해 입력 시간을 단축
▪ 커뮤니티
· 짠테크·재테크 노하우를 나누는 게시판
· 추천·댓글로 소통하고 좋은 글을 발견
▪ 그 외
· 다크 모드 지원
· 엑셀 백업/복구로 내 데이터를 안전하게 (프리미엄)
· 로그인 없이 '둘러보기'로 먼저 체험
무료로 핵심 가계부 기능을 모두 사용할 수 있으며, 통계·예산·고정지출 자동화·영수증 인식·백업 등은 프리미엄에서 제공됩니다. (구독 기능은 순차적으로 제공될 예정입니다.)
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
문의: dondwaeji.app@gmail.com
```
### 기타 등록정보 필드
- **앱 카테고리**: 금융 (Finance)
- **태그**: 가계부, 예산, 자산관리
- **연락처 이메일**: dondwaeji.app@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·AI(Anthropic)는 대행 처리(처리 위탁)이며 Google Play 기준 '공유'에 해당하지 않음)
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
### 수집하는 데이터 유형 (각 항목: 수집됨 / 목적 / 공유 여부)
| 데이터 유형 | 수집 | 공유 | 목적 |
|---|---|---|---|
| **개인 정보 → 이름** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **개인 정보 → 이메일 주소** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **금융 정보 → 기타 금융 정보** (사용자가 입력한 수입·지출·자산 등 가계부 데이터) | 예 | 아니요 | 앱 기능 |
| **사진 및 동영상 → 사진** (프로필·영수증·게시판 이미지) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 사용자가 생성한 콘텐츠** (게시판 글·댓글) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 앱 상호작용** | 예 | 아니요 | 앱 기능, 부정 이용 방지 |
각 항목 공통:
- **수집이 필수인가요?** → "데이터 수집이 필수임" (서비스 제공에 필요)
- **데이터가 암호화되나요(전송 중)?** → 예
- **삭제 요청 가능?** → 예
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
### AI 편의 기능의 데이터 처리 (처리 위탁, 공유 아님)
> 아래는 개인정보처리방침 3항과 정합. 회원이 해당 기능을 쓸 때에만, 기능 수행 목적으로 Anthropic(Claude) API에 전송됨. Anthropic은 학습에 사용하지 않고, 서비스도 별도 저장하지 않음(폼 자동 채움용). API 키는 서버 보관.
> - 자연어 한 줄 입력: 입력 텍스트 → 금액·분류 등 구조화
> - 영수증 사진 구조화: 영수증 이미지 → 금액·상호·날짜·분류
> - 분류·계좌 자동 추천: 분류·계좌 '이름' 목록
> - 재무 코멘트: 이번 달 **집계 수치만**(개별 거래 원문 미전송)
>
> Play 데이터 안전에서 위 데이터 유형(금융 정보·사진)의 **공유 여부는 계속 '아니요'** (대행 처리는 공유 아님). 다만 방침에 처리자(Anthropic)를 명시했으므로 심사자 교차확인에도 일치함.
---
## 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

+49 -31
View File
@@ -1,16 +1,23 @@
# Slim Budget — 프론트엔드 (sb_pt)
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 하나의 코드베이스로 **웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron)** 3개 플랫폼을 빌드합니다.
## 기술 스택
- **Vue 3** (`<script setup>` Composition API) + **Vite**
- **Pinia** (상태관리), **Vue Router** (라우팅)
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
- **Capacitor**(안드로이드 하이브리드 앱) · **Electron**(Windows 데스크톱) · **Vitest**(테스트)
## 브랜딩
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
- 로고: 돈돼지(투명 배경) — 헤더/사이드바 로고·파비콘·앱 런처 아이콘. 브랜드 그린 `#00bc7e`
## 플랫폼별 동작
- **웹(PC·모바일)**: 안내 페이지([WebOnlyNotice.vue](../src/components/WebOnlyNotice.vue))만 노출(앱 다운로드 유도). 실제 이용은 앱에서. `npm run dev` 는 예외로 전체 앱 표시
- **안드로이드**: Capacitor WebView 패키징 → [ANDROID.md](ANDROID.md)
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
- 기능 플래그 [src/config/features.js](../src/config/features.js): `ID_LOGIN_ENABLED`(아이디 로그인)·`BILLING_ENABLED`(인앱결제)·`ADS_ENABLED`(광고)·`CARD_NOTIF_ENABLED`(카드알림) — 첫 출시 기준 전부 `false`
## 디렉터리 구조
```
@@ -21,65 +28,76 @@ src/
│ ├── boardApi.js # 게시판
│ ├── adminApi.js # 관리자(태그 카테고리)
│ └── accountApi.js # 가계부 전체
│ ├── billingApi.js # 인앱결제/구독
├── stores/ # Pinia (auth, ui, user)
├── config/features.js # 기능 플래그(아이디로그인·결제·광고·카드알림)
├── router/index.js # 라우트 + 인증 가드
├── components/
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
│ ├── layout/ # AppHeader / AppSidebar / AppFooter / AppBottomNav
│ ├── ui/ # IconBtn / AppModal(전체화면 슬라이드업) / AppDialog / CategoryPicker
│ ├── editor/ # MarkdownEditor / MarkdownViewer
│ ├── LoginModal.vue / SignupModal.vue
│ ├── LoginModal.vue / SignupModal.vue / AppLockScreen.vue(PIN) / AdBanner.vue / WebOnlyNotice.vue
├── views/
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
│ ├── UsersView.vue # 사용자 관리
│ ├── HomeView.vue # 홈 대시보드(요약·일별 캘린더)
│ ├── board/ # 게시판 (목록/상세/작성)
│ ├── account/ # 가계부 (아래 참조)
│ └── admin/ # 태그 관리(관리자)
└── assets/main.css # 전역 스타일
│ └── admin/ # 회원/태그 관리(관리자)
└── assets/ # base.css(data-theme 다크모드) 등 전역 스타일
```
## 인증
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
- **구글 로그인**(웹/PC=Google Identity Services, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(`ID_LOGIN_ENABLED=false` 로 아이디/비번 폼·회원가입 UI 숨김)
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
## 레이아웃 / 반응형
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
## 레이아웃 / 반응형 / 테마
- 그리드 레이아웃 — 사이드바가 좌측 상단(헤더 행)까지, 헤더는 현재 화면 타이틀 표시. 본문 폭 `--page-max`(760px)로 통일
- **하단 고정 내비**([AppBottomNav.vue](../src/components/layout/AppBottomNav.vue)): 뒤로·앞으로·홈·새로고침·설정 (안드로이드 소프트키 위 안전영역)
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
- **입력 모달**: 전체화면 슬라이드업([AppModal.vue](../src/components/ui/AppModal.vue), 바깥 닫힘 방지). 셀렉트 대신 **뱃지/칩** 선택, 분류는 공통 [CategoryPicker.vue](../src/components/ui/CategoryPicker.vue)(대/소분류 아코디언)
- **테마**: 시스템 / 라이트 / 다크 (설정 화면, `data-theme` 기반)
- 모든 액션 버튼은 **아이콘화**(`IconBtn`), `title`로 접근성·툴팁 제공
## 게시판 (`/board`)
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
- 댓글, 태그, 관리자 **열람 제한/해제**
- **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판(카테고리)
- 목록(페이징·태그 필터·검색[제목/본문/댓글]·공지 최상단), 상세, 작성/수정(마크다운·이미지 업로드)
- 댓글, 추천(vote), 태그, **공지**(관리자), 관리자 **열람 제한/해제**, 신고
## 가계부 (`/account`)
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
| 경로 | 화면 | 설명 |
|------|------|------|
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**(대분류 집계), 수입대비지출(월말 예상·지난달 대비), 월별 총액, **순자산 추이**(주간) 라인차트, 일별 캘린더 |
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 날짜 그룹 접기/펴기, **외화 입력**(환율 자동환산), 내역→고정지출 바로 등록 |
| `/account/recurrings` | 고정 지출 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이·'확인 필요'로 생성) |
| `/account/wallets` | 계좌 관리 | 은행/**현금**/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부(원금·이자·연회비 분리), 월별 이용내역 |
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD(**대/소분류 2단계**), 칩 그리드 관리, 순서변경, 기존 내역에서 가져오기 |
| `/account/budget` | 예산 설정 | **월별 예산**(전월/다음달 복사), 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
### 잔액·순자산 모델 (표시)
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
- 자산(+) / 부채(−) 부호 모델. 현금은 은행과 동일하게 자산 합산. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
### 투자 계좌 (계좌 관리 > 투자 탭)
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
- **단순형 UI**: **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산(퇴직연금·연금 등 포함). 계좌 펼침 시 투자금/평가액/손익(%) 요약 카드
- 입금은 가계부 **이체**(은행→투자)로, 평가액은 순자산·추이에 반영
- ℹ️ 백엔드는 종목 단위(매수·매도·시세 자동조회) API 를 유지하나 현재 UI 에는 노출하지 않음. 원화 기준
## 설정 화면
- 하단 내비 → 설정: 계정정보(이름·이메일·비밀번호 변경) · 테마(시스템/라이트/다크) · 앱 잠금(PIN, 1시간 유예) · 앱 버전 · App Data 삭제 · (PC)창 크기 프리셋
## 개발/빌드
```bash
npm install
npm run dev # 개발 서버
npm run build # dist/ 정적 빌드
npm run dev # 개발 서버(전체 앱 표시)
npm run build # dist/ 정적 빌드(웹 배포)
npm run cap:sync # 앱 빌드 + 안드로이드 동기화
npm run electron:build # Windows 설치파일
npm test # Vitest
```
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
- 상세: [../README.md](../README.md) · 앱 [ANDROID.md](ANDROID.md) · 배포 [../deploy/README.md](../deploy/README.md)
+81
View File
@@ -0,0 +1,81 @@
# iOS 하이브리드 앱 (Capacitor)
Vue `dist` 를 네이티브 WebView로 패키징해 iOS 앱으로 빌드합니다. 안드로이드와 **동일 코드베이스**이며, 플랫폼 분기는 `Capacitor.isNativePlatform()` / `getPlatform() === 'ios'` 로 처리합니다.
## 구성
- `capacitor.config.json` — appId `kr.sblog.slimbudget`(안드와 동일), appName `돈돼지 가계부`, webDir `dist`
- `ios/` — 네이티브 프로젝트(커밋됨, 빌드 산출물 `ios/App/build`·`Pods` 는 .gitignore)
- **Capacitor 8 = Swift Package Manager(SPM)** 사용 (CocoaPods 아님 — Podfile 불필요)
- 웹 게이팅: iOS 는 `isNativePlatform()` 이 true → 안내 페이지가 아닌 전체 앱 표시
## 사전 준비 (Mac)
- **Xcode**(App Store, 대용량) + iOS SDK/시뮬레이터 런타임
```bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
xcodebuild -downloadPlatform iOS # 시뮬레이터 런타임(별도 온디맨드 다운로드)
```
- **Apple Developer Program($99/년)** — 실기기 배포·App Store·Apple 로그인 Capability 에 필요. (시뮬레이터 UI 테스트는 불필요)
## 최초 셋업 (1회)
```bash
npm i @capacitor/ios
npx cap add ios # ios/ 생성
npm run build:app && npx cap sync ios
npx capacitor-assets generate --ios # 브랜드 아이콘 생성(assets/ 기준)
```
## 빌드 & 실행
```bash
npm run build:app && npx cap sync ios # 코드 변경 시마다
npx cap open ios # Xcode 열기
# 또는 CLI 로 시뮬레이터에 바로:
npx cap run ios --target <시뮬레이터-UDID>
```
시뮬레이터 목록: `xcrun simctl list devices available | grep iPhone`
### 실기기 실행 (Xcode)
1. **데이터 지원 케이블**로 아이폰 연결 → "이 컴퓨터 신뢰" (충전 전용 케이블은 인식 안 됨)
2. Xcode → **App 타겟 → Signing & Capabilities → Automatically manage signing → Team 선택**(무료 Apple ID 도 7일 한정 가능)
3. 상단 툴바 중앙 **기기 선택**(스킴 `App` 옆) → 연결된 아이폰 → **▶ Run**
4. 최초엔 아이폰이 **개발자 모드**를 요구 → Run 시도 후 **설정 → 개인정보 보호 및 보안 → 개발자 모드 → 켬 → 재시동**
5. "신뢰할 수 없는 개발자" → **설정 → 일반 → VPN 및 기기 관리 → 개발자 앱 → 신뢰**
## 필수 설정 (반영 완료)
- **번들 ID** `kr.sblog.slimbudget` (안드로이드와 동일 — App Store Connect 에서 그대로 사용)
- **Info.plist 권한 문구** (없으면 카메라/사진 접근 시 크래시):
- `NSCameraUsageDescription`(영수증·프로필·게시판 촬영)
- `NSPhotoLibraryUsageDescription` / `NSPhotoLibraryAddUsageDescription`
- **safe-area**: `index.html` 의 `viewport-fit=cover` + CSS `env(safe-area-inset-*)`.
⚠️ 전역 `box-sizing: border-box` 에서 고정 `height` 를 쓰면 safe-area 패딩이 높이에 포함돼 콘텐츠가 눌린다 → 헤더는 `min-height`+세로패딩, 하단 내비는 `height: calc(56px + env(safe-area-inset-bottom))` 로 처리(적용됨).
## Apple 로그인 (Sign in with Apple)
> App Store 심사 가이드라인 **4.8**: 제3자 소셜 로그인(구글)을 제공하면 **Apple 로그인도 동등하게 제공**해야 함. iOS 출시에 **사실상 필수**.
**구현 상태(코드 완료):**
- 프론트: `@capacitor-community/apple-sign-in` + [LoginModal.vue](../src/components/LoginModal.vue) Apple 버튼(iOS 전용) + [appleAuth.js](../src/native/appleAuth.js)
- ⚠️ 이 플러그인은 Capacitor 7 기준(`capacitor-swift-pm 7.x`) → **patch-package** 로 8.x 로 패치(`patches/`). `npm ci` 후 `postinstall` 이 자동 적용. @capawesome 구글 플러그인과 iOS 공존.
- 백엔드: `POST /api/auth/apple` — **nimbus-jose-jwt 로 Apple JWKS(공개키) 서명 검증** + iss/aud(번들ID)/exp 확인, `member.apple_id` 로 구글과 동일하게 이메일 자동연결. `app.apple-client-id`(기본 `kr.sblog.slimbudget`).
- `ios/App/App/App.entitlements` — `applesignin` 엔틀먼트 준비됨.
**작동시키려면(유료 승인 후):**
1. Apple Developer 포털: App ID `kr.sblog.slimbudget` 에 **Sign in with Apple 활성화**
2. Xcode → Signing & Capabilities → **+ Capability → "Sign in with Apple"** (준비된 `App.entitlements` 연결 + 프로비저닝)
3. 테스트: 시뮬레이터 **설정 → Apple ID 로그인** 후 `cap run ios` → Apple 버튼
- Sign in with Apple 은 **시뮬레이터에서도 작동**(카메라/푸시와 달리). 단 1~2 가 갖춰져야 함.
## 배포 (App Store)
- **의무 테스트 기간 없음** — 안드로이드(Play)의 "테스터 12명·14일"과 달리, iOS 는 **바로 심사 제출 가능**. TestFlight 은 **선택**.
- 절차: App Store Connect 앱 생성 → 아이콘·스크린샷(6.x" iPhone 규격)·App Privacy 작성 → Xcode Archive → 업로드 → 심사(보통 1~3일)
- **TestFlight**(선택): 유료 승인 후 빌드 업로드 → **케이블 없이 아이폰에 무선 설치**(데이터 케이블 없을 때 유용). 내부 100명 즉시 / 외부 10,000명(가벼운 베타 심사).
## 자주 막히는 부분
- **SPM 의존성 충돌**: 플러그인이 서로 다른 `capacitor-swift-pm`/`googlesignin-ios` 버전을 요구하면 빌드 실패. Capacitor 8 전용 플러그인을 쓰거나(patch-package) 버전 정렬 필요.
- **USB 연결이 충전만 됨**: 충전 전용 케이블 → **데이터 케이블**로 교체. 무선 디버깅도 최초 1회 유선 페어링 필요.
- **개발자 모드 메뉴가 없음**: Xcode 로 기기 설치를 **한 번 시도한 뒤** 설정에 항목이 생김(iOS 16+).
- **Developer 등록 "보류"**: 개인 등록은 Apple 이 추가 본인확인을 요청할 수 있음 → **Apple ID 이메일 확인 후 회신**해야 진행됨(자동으로 안 풀림).
- **카메라/푸시는 시뮬레이터 제한** → 실기기 필요. Apple 로그인은 시뮬레이터 가능.
## 참고
- 안드로이드 빌드: [ANDROID.md](ANDROID.md) · 아키텍처: [FRONTEND.md](FRONTEND.md)
- 앱 전용 백엔드 URL: `.env.capacitor` 의 `VITE_API_BASE_URL`(HTTPS 필수)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>돈돼지 가계부</title>
</head>
<body>
+13
View File
@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
+393
View File
@@ -0,0 +1,393 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
327E84D03001463F00AB0467 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
327E84D03001463F00AB0467 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = App;
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 2660;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "CapApp-SPM";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
isa = XCSwiftPackageProductDependency;
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,105 @@
{
"originHash" : "275f5197fc45ecbed402538938c9f019518bacb33270bc32cf5549af9b2891e6",
"pins" : [
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
},
{
"identity" : "capacitor-swift-pm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
"state" : {
"revision" : "2231987d85b8b0b289320b1d0947b4ae8345cde4",
"version" : "8.4.1"
}
},
{
"identity" : "googlesignin-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleSignIn-iOS",
"state" : {
"revision" : "65fb3f1aa6ffbfdc79c4e22178a55cd91561f5e9",
"version" : "8.0.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.2"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b",
"version" : "3.5.0"
}
},
{
"identity" : "gtmappauth",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GTMAppAuth.git",
"state" : {
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a",
"version" : "4.1.1"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "ion-ios-camera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/ion-ios-camera.git",
"state" : {
"revision" : "2cd4f06b0ece21b6458e23357280c51f94bfceb8",
"version" : "1.0.4"
}
},
{
"identity" : "ion-ios-filesystem",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/ion-ios-filesystem.git",
"state" : {
"revision" : "0d81e26e828ff9582807e2339112cedf2e0fab85",
"version" : "1.1.2"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 3
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
+49
View File
@@ -0,0 +1,49 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CAPACITOR_DEBUG</key>
<string>$(CAPACITOR_DEBUG)</string>
<key>NSCameraUsageDescription</key>
<string>영수증·프로필·게시판 사진을 촬영할 때 카메라를 사용합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>영수증·프로필·게시판 사진을 첨부할 때 사진 보관함에서 이미지를 불러옵니다.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>촬영한 사진을 사진 보관함에 저장할 때 사용합니다.</string>
<key>GIDClientID</key>
<string>127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne.apps.googleusercontent.com</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne</string>
</array>
</dict>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>돈돼지 가계부</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+41
View File
@@ -0,0 +1,41 @@
// swift-tools-version: 5.9
import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package(
name: "CapApp-SPM",
platforms: [.iOS(.v15)],
products: [
.library(
name: "CapApp-SPM",
targets: ["CapApp-SPM"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
.package(name: "CapacitorCommunityAppleSignIn", path: "../../../node_modules/@capacitor-community/apple-sign-in"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorCamera", path: "../../../node_modules/@capacitor/camera"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapawesomeCapacitorGoogleSignIn", path: "../../../node_modules/@capawesome/capacitor-google-sign-in")
],
targets: [
.target(
name: "CapApp-SPM",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorCommunityAppleSignIn", package: "CapacitorCommunityAppleSignIn"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorCamera", package: "CapacitorCamera"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapawesomeCapacitorGoogleSignIn", package: "CapawesomeCapacitorGoogleSignIn")
]
)
]
)
+5
View File
@@ -0,0 +1,5 @@
# CapApp-SPM
This package is used to host SPM dependencies for your Capacitor project
Do not modify the contents of it or there may be unintended consequences.
@@ -0,0 +1 @@
public let isCapacitorApp = true
+1
View File
@@ -0,0 +1 @@
CAPACITOR_DEBUG = true
+407 -6
View File
@@ -8,12 +8,14 @@
"name": "sb_pt",
"version": "1.0.0",
"dependencies": {
"@capacitor-community/apple-sign-in": "^7.1.0",
"@capacitor/android": "^8.3.4",
"@capacitor/app": "^8.1.0",
"@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/ios": "^8.4.1",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
@@ -43,6 +45,7 @@
"jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"patch-package": "^8.0.1",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.8"
@@ -600,6 +603,19 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@capacitor-community/apple-sign-in": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/apple-sign-in/-/apple-sign-in-7.1.0.tgz",
"integrity": "sha512-df7cA7vfvvvilTtpJKeVvxO31W+py5t3HK9233GBD5MvgAgtGvCqeQ3pI0noNkVsKPF9B1g/po8Mph6s4bVSmw==",
"license": "MIT",
"dependencies": {
"@types/scriptjs": "0.0.2",
"scriptjs": "^2.5.9"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/android": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.4.tgz",
@@ -1183,9 +1199,9 @@
}
},
"node_modules/@capacitor/core": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.1.tgz",
"integrity": "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
@@ -1203,6 +1219,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
"integrity": "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^8.4.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
@@ -3552,6 +3577,12 @@
"@types/node": "*"
}
},
"node_modules/@types/scriptjs": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@types/scriptjs/-/scriptjs-0.0.2.tgz",
"integrity": "sha512-GFrUgzGYfNX3VWZwMyzr0JrBwzLv5Kes4BQBvo6hXdRy14/GBbpCiVwwB7v1o2xIEvFf+9GyznmYhuesQQjSag==",
"license": "MIT"
},
"node_modules/@types/slice-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz",
@@ -4003,6 +4034,13 @@
"node": ">=14.6"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -4778,6 +4816,19 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -5169,6 +5220,25 @@
"node": ">=8"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -5182,6 +5252,23 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -6174,7 +6261,6 @@
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
@@ -7458,6 +7544,19 @@
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -7475,6 +7574,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -8104,7 +8213,6 @@
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
@@ -8499,6 +8607,16 @@
"node": ">=8"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-obj": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
@@ -8905,6 +9023,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -8912,6 +9050,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -8950,6 +9095,16 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -8997,6 +9152,16 @@
"node": ">=0.10.0"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -9762,6 +9927,33 @@
"semver": "bin/semver"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@@ -10489,7 +10681,6 @@
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -10819,6 +11010,169 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/patch-package/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/patch-package/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -12180,6 +12534,12 @@
"node": ">=v12.22.7"
}
},
"node_modules/scriptjs": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz",
"integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==",
"license": "MIT"
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
@@ -12244,6 +12604,24 @@
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/sharp": {
"version": "0.32.6",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
@@ -12438,6 +12816,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/slice-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
@@ -13230,6 +13618,19 @@
"tmp": "^0.2.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+4
View File
@@ -5,6 +5,7 @@
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"postinstall": "patch-package",
"dev": "vite",
"build": "vite build",
"build:app": "vite build --mode capacitor",
@@ -41,12 +42,14 @@
}
},
"dependencies": {
"@capacitor-community/apple-sign-in": "^7.1.0",
"@capacitor/android": "^8.3.4",
"@capacitor/app": "^8.1.0",
"@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/ios": "^8.4.1",
"@capacitor/local-notifications": "^8.2.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
@@ -76,6 +79,7 @@
"jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"patch-package": "^8.0.1",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.8"
@@ -0,0 +1,13 @@
diff --git a/node_modules/@capacitor-community/apple-sign-in/Package.swift b/node_modules/@capacitor-community/apple-sign-in/Package.swift
index 94246df..7d9d007 100644
--- a/node_modules/@capacitor-community/apple-sign-in/Package.swift
+++ b/node_modules/@capacitor-community/apple-sign-in/Package.swift
@@ -10,7 +10,7 @@ let package = Package(
targets: ["SignInWithApple"])
],
dependencies: [
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
+234 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
@@ -9,6 +9,9 @@ import LoginModal from '@/components/LoginModal.vue'
import SignupModal from '@/components/SignupModal.vue'
import WebOnlyNotice from '@/components/WebOnlyNotice.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 { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth'
@@ -47,10 +50,72 @@ function onUnauthorized() {
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(() => {
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
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() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
if (auth.isAuthenticated) {
@@ -69,6 +134,20 @@ onMounted(() => {
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 // 상위로 전파 중단
})
// 페이지 이동 시 모바일 사이드바 자동 닫힘
@@ -76,6 +155,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
</script>
<template>
<!-- 치명적 오류 폴백 ( 화면 방지) -->
<div v-if="appError.hasError" class="fatal-screen">
<div class="fatal-inner">
<div class="fatal-emoji">😵</div>
<h1 class="fatal-title">문제가 발생했어요</h1>
<p class="fatal-sub">일시적인 오류일 있어요. 새로고침 다시 시도해 주세요.</p>
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
</div>
</div>
<template v-else>
<!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 -->
<template v-if="!isApp">
<RouterView v-if="isPublicLegal" />
@@ -84,6 +174,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<!-- (Capacitor): 전체 기능 -->
<template v-else>
<!-- 잠금 화면 (로그인 상태 + 잠금 활성 ) -->
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
<!-- 네트워크 오류/오프라인 안내 배너 -->
<Transition name="netbar">
<div v-if="showNetBanner" class="net-banner" role="alert">
<span class="net-text">{{ netBannerText }}</span>
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
</div>
</Transition>
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
<AppHeader class="layout-top" />
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) -->
@@ -108,11 +209,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<LoginModal />
<SignupModal />
<!-- 토스트 (뒤로가기 종료 안내 ) -->
<Transition name="toast">
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
</Transition>
</template>
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
<AppDialog />
</template>
</template>
<style scoped>
.layout {
@@ -123,7 +230,10 @@ watch(() => route.fullPath, () => ui.closeSidebar())
'bottom bottom';
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
/* 앱 셸: 뷰포트 높이에 고정하고 본문(layout-body)만 스크롤 → 상단 헤더·하단 내비 고정, 페이지 전체 스크롤 방지 */
height: 100vh; /* 폴백 */
height: 100dvh; /* 동적 뷰포트(주소창·안전영역 반영) */
overflow: hidden;
}
.layout-top {
@@ -147,6 +257,122 @@ watch(() => route.fullPath, () => ui.closeSidebar())
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;
@@ -171,9 +397,13 @@ watch(() => route.fullPath, () => ui.closeSidebar())
.layout-body {
grid-area: body;
padding: 1.5rem 2rem;
/* 하단 고정 내비(56px)+소프트키 인셋만큼 비워 콘텐츠가 가리지 않게 */
/* 하단 고정 내비(56px + 안전영역) 뒤로 마지막 내용이 가려지지 않도록 항상 여백 확보 */
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0) + 1rem);
overflow: auto;
/* 그리드 1fr 트랙 안에서만 스크롤(콘텐츠로 인해 트랙이 늘어나 페이지 전체가 스크롤되지 않도록 min-height:0) */
min-height: 0;
overflow-y: auto;
overflow-x: hidden; /* 페이지 가로 스크롤 방지 — 넓은 표/차트는 각자 컨테이너에서 스크롤 */
-webkit-overflow-scrolling: touch;
}
.layout-bottom {
+63 -8
View File
@@ -15,6 +15,10 @@ const realApi = {
categoryStats({ type, year, month } = {}) {
return http.get('/account/category-stats', { params: { type, year, month } })
},
// 통계 화면 집계 → AI 재무 코멘트(유료). 실패해도 화면을 막지 않도록 전역 배너 억제
aiComment(payload) {
return http.post('/account/ai-comment', payload, { timeout: 30000, suppressNetError: true })
},
netWorthTrend({ months, weeks, unit } = {}) {
return http.get('/account/networth/trend', { params: { months, weeks, unit } })
},
@@ -28,8 +32,13 @@ const realApi = {
return http.delete(`/account/entries/${id}`)
},
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
parseText({ title, text } = {}) {
return http.post('/account/entries/parse', { title, text })
// categoryHints/walletHints: AI가 이 후보들 중에서 분류·결제수단을 고르도록 사용자 목록 전달
parseText({ title, text, categoryHints, walletHints } = {}) {
return http.post('/account/entries/parse', { title, text, categoryHints, walletHints })
},
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') {
return http.get('/fx/rate', { params: { from, to } })
},
// 자주 쓰는 내역(빠른 등록 템플릿)
@@ -72,8 +81,12 @@ const realApi = {
},
// 예산 (사용자별)
budgets() {
return http.get('/account/budgets')
budgets({ year, month } = {}) {
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 }) {
return http.get('/account/budgets/status', { params: { year, month } })
@@ -87,8 +100,8 @@ const realApi = {
setExpectedIncome({ year, month, expectedIncome }) {
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
},
createBudget(payload) {
return http.post('/account/budgets', payload)
createBudget(payload, { year, month } = {}) {
return http.post('/account/budgets', payload, { params: { year, month } })
},
updateBudget(id, payload) {
return http.put(`/account/budgets/${id}`, payload)
@@ -96,6 +109,19 @@ const realApi = {
removeBudget(id) {
return http.delete(`/account/budgets/${id}`)
},
// 1회성 예산
onetimeBudgets() {
return http.get('/account/budgets/onetime')
},
createOnetimeBudget(payload) {
return http.post('/account/budgets/onetime', payload)
},
updateOnetimeBudget(id, payload) {
return http.put(`/account/budgets/onetime/${id}`, payload)
},
removeOnetimeBudget(id) {
return http.delete(`/account/budgets/onetime/${id}`)
},
createWallet(payload) {
return http.post('/account/wallets', payload)
},
@@ -194,13 +220,42 @@ const realApi = {
return http.post('/account/restore', payload)
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
// 가계부 데이터 전체 삭제 (내역·계좌·분류·태그·예산·고정지출·투자 전부)
deleteAllData() {
return http.delete('/account/all')
},
// 포인트 상점 — 현황 조회 / 아이템 구매
shopStatus() {
return http.get('/account/shop/status')
},
buyShopItem(item) {
return http.post('/account/shop/buy', { item })
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
// suppressNetError: runOcr 이 자체 formError 로 안내하므로 전역 배너 억제
ocrReceipt(blob) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
return http.post('/account/ocr/receipt', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
suppressNetError: true,
})
},
// 영수증 AI 구조화 (Claude Vision) — 금액·상호·날짜·분류·계좌를 한 번에. 사용자 후보 목록을 함께 전달
// 비전 처리가 느릴 수 있어 타임아웃 상향, 실패 시 OCR 폴백이 있으므로 전역 배너 억제
parseReceiptAi(blob, { categoryHints = [], walletHints = [] } = {}) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
// 멀티파트 파트 수 제한(Tomcat 기본 10) 회피: 후보를 각각 한 파트에 줄바꿈으로 합쳐 전송
if (categoryHints.length) fd.append('categoryHints', categoryHints.join('\n'))
if (walletHints.length) fd.append('walletHints', walletHints.join('\n'))
return http.post('/account/ocr/receipt-ai', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
suppressNetError: true,
})
},
}
@@ -224,7 +279,7 @@ const DEMO_WRITES = new Set([
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData', 'buyShopItem',
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices',
+8 -3
View File
@@ -18,6 +18,10 @@ export const authApi = {
googleClientId() {
return http.get('/auth/google-client-id')
},
// 애플 로그인/가입 — Sign in with Apple 에서 받은 identity token 전달
appleLogin(payload) {
return http.post('/auth/apple', payload)
},
logout() {
return http.post('/auth/logout')
},
@@ -43,9 +47,10 @@ export const authApi = {
points() {
return http.get('/auth/points')
},
// 포인트 적립/차감 내역 (최신순)
pointHistory() {
return http.get('/auth/point-history')
// 포인트 적립/차감 내역 — year+month 지정 시 해당 월, 미지정 시 최근 100건
pointHistory(year, month) {
const params = year && month ? { year, month } : {}
return http.get('/auth/point-history', { params })
},
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
profile() {
+8
View File
@@ -76,6 +76,14 @@ export const boardApi = {
unblockComment(commentId) {
return http.post(`/board/comments/${commentId}/unblock`)
},
// 신고 관리 (관리자) — 신고된 글/댓글 목록
reports() {
return http.get('/board/reports')
},
// 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화
dismissReport(targetType, targetId) {
return http.post('/board/reports/dismiss', { targetType, targetId })
},
block(id, reason) {
return http.post(`/board/posts/${id}/block`, { reason })
},
+4
View File
@@ -34,6 +34,10 @@ http.interceptors.response.use(
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required'))
} else if (!error.response && error.code !== 'ERR_CANCELED' && !error.config?.suppressNetError) {
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
// suppressNetError: 자체 폴백/에러표시가 있는 호출(예: 영수증 AI)은 전역 배너를 띄우지 않음.
window.dispatchEvent(new CustomEvent('net:error'))
}
return Promise.reject(error)
},
+3 -8
View File
@@ -1,18 +1,13 @@
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app'
import { handleBack } from '@/composables/appBack'
export function setupCapacitor(router) {
if (!Capacitor.isNativePlatform()) return
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료
CapApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack && window.history.length > 1) {
router.back()
} else {
CapApp.exitApp()
}
})
// 안드로이드 하드웨어 뒤로가기 → 하단 내비 뒤로 버튼과 동일한 통합 처리
CapApp.addListener('backButton', () => handleBack(router))
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
document.documentElement.classList.add('is-native')
+96
View File
@@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue'
import { appLock, PIN_LENGTH } from '@/composables/appLock'
import { useAuthStore } from '@/stores/auth'
import PinPad from '@/components/PinPad.vue'
const auth = useAuthStore()
const pin = ref('')
const error = ref(false)
async function onComplete(p) {
if (await appLock.verify(p)) {
pin.value = ''
error.value = false
appLock.unlock()
} else {
error.value = true
pin.value = ''
}
}
function onInput(v) {
pin.value = v
error.value = false
}
async function forgot() {
// PIN 분실 → 잠금 해제 + 로그아웃(재로그인 후 다시 설정). 갇힘 방지.
appLock.disable()
await auth.logout()
appLock.unlock()
}
</script>
<template>
<div class="lock-screen">
<div class="lock-inner">
<div class="lock-logo">🐷</div>
<h1 class="lock-title">돈돼지 가계부</h1>
<p class="lock-sub" :class="{ err: error }">
{{ error ? 'PIN이 일치하지 않아요' : 'PIN을 입력하세요' }}
</p>
<PinPad :value="pin" :length="PIN_LENGTH" class="lock-pad" @update:value="onInput" @complete="onComplete" />
<button type="button" class="lock-forgot" @click="forgot">PIN을 잊으셨나요? 로그아웃</button>
</div>
</div>
</template>
<style scoped>
.lock-screen {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: calc(env(safe-area-inset-top, 0) + 1rem) 1rem calc(env(safe-area-inset-bottom, 0) + 1rem);
background: var(--color-background);
}
.lock-inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.lock-logo {
font-size: 2.6rem;
}
.lock-title {
margin-top: 0.4rem;
font-size: 1.25rem;
color: var(--color-heading);
}
.lock-sub {
margin-top: 0.6rem;
margin-bottom: 1.8rem;
font-size: 0.92rem;
opacity: 0.7;
}
.lock-sub.err {
color: #c0392b;
opacity: 1;
font-weight: 600;
}
.lock-forgot {
margin-top: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.6;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.lock-forgot:hover {
opacity: 0.9;
}
</style>
+68
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { authApi } from '@/api/authApi'
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
import { isNativeApple, nativeAppleCredential } from '@/native/appleAuth'
import { ID_LOGIN_ENABLED } from '@/config/features'
const auth = useAuthStore()
@@ -64,6 +65,33 @@ async function handleNativeGoogle() {
}
}
// ===== 애플 로그인 (iOS 전용) =====
const isApple = isNativeApple()
async function handleNativeApple() {
if (loading.value) return
loading.value = true
try {
const { identityToken, name } = await nativeAppleCredential()
loading.value = false
if (!identityToken) return
error.value = null
loading.value = true
await auth.appleLogin(identityToken, name, form.rememberMe)
const redirect = ui.redirectPath
ui.closeLogin()
router.push(redirect || '/')
} catch (e) {
// 사용자가 취소(USER_CANCELLED/1001 등)한 경우는 조용히 무시
const msg = `${e?.code || ''} ${e?.message || ''}`
if (!/cancel|1001/i.test(msg)) {
error.value = e.response?.data?.message || '애플 로그인에 실패했습니다.'
}
} finally {
loading.value = false
}
}
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
function loadGisScript() {
if (window.google?.accounts?.id) return Promise.resolve()
@@ -208,6 +236,20 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<div v-else ref="googleBtn" class="google-btn"></div>
</div>
<!-- 애플 로그인 iOS 전용(App Store 가이드라인 4.8 대응). 서버 설정 불필요 -->
<button
v-if="isApple"
type="button"
class="apple-native"
:disabled="loading"
@click="handleNativeApple"
>
<svg class="a-logo" viewBox="0 0 384 512" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
</svg>
Apple로 계속하기
</button>
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
네이버로 로그인 (준비 )
</button>
@@ -339,6 +381,32 @@ h2 {
.google-native .g-logo {
flex: none;
}
/* Apple 로그인 버튼 — HIG 권장(검은 배경 + 흰 로고/텍스트) */
.apple-native {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
max-width: 300px;
margin: 0.6rem auto 0;
padding: 0.6rem 1rem;
border: 1px solid #000;
border-radius: 4px;
background: #000;
color: #fff;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
}
.apple-native:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.apple-native .a-logo {
flex: none;
margin-top: -2px;
}
.divider {
display: flex;
align-items: center;
+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>
+12 -5
View File
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { handleBack } from '@/composables/appBack'
const router = useRouter()
const route = useRoute()
@@ -9,9 +10,8 @@ const isHome = computed(() => route.path === '/')
const isSettings = computed(() => route.path.startsWith('/settings'))
function goBack() {
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
if (window.history.length > 1) router.back()
else router.push('/')
// 하드웨어 뒤로가기와 동일 동작(오버레이 닫기 → 화면 뒤로 → 최상위 종료 안내)
handleBack(router)
}
function goForward() {
router.forward()
@@ -74,11 +74,18 @@ function goSettings() {
display: flex;
align-items: stretch;
justify-content: space-around;
height: 56px;
/* 안드로이드 소프트키(시스템 내비게이션 바) 위에 위치 */
/* 아이콘 행은 항상 56px 확보 + 그 아래로 홈 인디케이터(safe-area) 공간을 더한다.
(box-sizing:border-box 전역이라 height 에 인셋을 더해야 아이콘 행이 눌리지 않음) */
height: calc(56px + env(safe-area-inset-bottom, 0));
/* 안드로이드 소프트키·iOS 홈 인디케이터 영역 확보 */
padding-bottom: env(safe-area-inset-bottom, 0);
background: var(--color-background-soft);
border-top: 1px solid var(--color-border);
/* 스크롤 콘텐츠와 시각적으로 분리(iOS 탭바처럼 은은한 상단 그림자) */
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.07);
}
:global([data-theme='dark']) .bottom-nav {
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.35);
}
.nav-btn {
flex: 1;
+9 -4
View File
@@ -19,7 +19,7 @@ const TITLES = {
'demo-locked': '둘러보기',
'account-entries': '가계부 내역',
'account-stats': '통계',
'account-recurrings': '고정 지출',
'account-recurrings': '정기결제',
'account-wallets': '계좌 관리',
'account-categories': '분류 관리',
'account-budget': '예산 설정',
@@ -28,6 +28,7 @@ const TITLES = {
users: '회원 관리',
'admin-tags': '태그 관리(관리자)',
'admin-default-categories': '기본 분류 설정',
'admin-reports': '신고 관리',
settings: '설정',
'settings-account': '계정정보',
'settings-account-edit': '가입정보 변경',
@@ -79,8 +80,11 @@ async function handleLogout() {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 1.5rem;
/* height 대신 min-height — 네이티브에서 layout-top 이 상단 safe-area 패딩을 더하므로
(box-sizing:border-box) 고정 height 면 콘텐츠가 눌린다. min-height 로 헤더가 커지게. */
min-height: 56px;
/* 세로 패딩으로 버튼/타이틀이 하단 구분선에 붙지 않게 (상단 패딩은 네이티브에서 layout-top 이 override). */
padding: 0.5rem 1.5rem;
background: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
}
@@ -140,7 +144,8 @@ async function handleLogout() {
display: inline-flex;
}
.app-header {
padding: 0 1rem;
/* 세로 패딩 유지(로그인 버튼이 하단 구분선에 붙지 않게). 상단은 네이티브에서 layout-top override. */
padding: 0.5rem 1rem;
}
/* 좁은 화면(폰/앱): 브랜드명 텍스트 숨기고 로고만 — 두 줄 줄바꿈 방지 */
.brand-text {
+15 -3
View File
@@ -25,6 +25,8 @@ const icons = {
board: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
repay: '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
}
</script>
@@ -51,13 +53,18 @@ const icons = {
</RouterLink>
<RouterLink to="/account/recurrings" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
<span>고정 지출</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
<span>정기결제</span>
<!-- 무료도 2건까지 사용 가능 🔒 미표시 -->
</RouterLink>
<RouterLink to="/account/wallets" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
<span>계좌 관리</span>
</RouterLink>
<RouterLink to="/account/repayments" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.repay" />
<span>대출·할부 상환</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink>
<RouterLink to="/account/categories" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.categories" />
<span>분류 관리</span>
@@ -106,6 +113,10 @@ const icons = {
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.sliders" />
<span>기본 분류 설정</span>
</RouterLink>
<RouterLink to="/admin/reports" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.flag" />
<span>신고 관리</span>
</RouterLink>
</template>
</nav>
</aside>
@@ -115,7 +126,8 @@ const icons = {
.app-sidebar {
background: var(--color-background-soft);
border-right: 1px solid var(--color-border);
padding: 0 0 1rem;
/* iOS 상태바(노치) 영역만큼 위 여백 — 드로어 상단이 상태바에 가리지 않도록 */
padding: env(safe-area-inset-top, 0) 0 1rem;
}
.drawer-head {
display: flex;
+163
View File
@@ -0,0 +1,163 @@
<script setup>
// 계좌 선택기: 종류(대분류) 클릭 → 해당 종류 계좌(소분류) 펼침. CategoryPicker 형태.
import { computed, ref, watch } from 'vue'
import BottomSheet from './BottomSheet.vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
wallets: { type: Array, default: () => [] }, // { id, name, type, issuer }
title: { type: String, default: '계좌 선택' },
placeholder: { type: String, default: '계좌를 선택하세요' },
disabled: { type: Boolean, default: false },
emptyText: { type: String, default: '선택할 계좌가 없습니다' },
allLabel: { type: String, default: '' }, // 필터용 '전체' 옵션(설정 시 노출)
})
const emit = defineEmits(['update:modelValue'])
const KINDS = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CASH', label: '현금' },
{ key: 'CARD', label: '카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'MINUS', label: '마이너스' },
{ key: 'INVEST', label: '투자' },
]
const groups = computed(() =>
KINDS.map((k) => ({ ...k, items: props.wallets.filter((w) => w.type === k.key) })).filter(
(g) => g.items.length,
),
)
const selected = computed(() => props.wallets.find((w) => w.id === props.modelValue) || null)
const triggerLabel = computed(() => {
if (selected.value) return selected.value.name
if (props.allLabel && !props.modelValue) return props.allLabel
return props.placeholder
})
const isEmptyState = computed(() => !selected.value && !(props.allLabel && !props.modelValue))
const open = ref(false)
const expanded = ref('')
const expandedGroup = computed(() => groups.value.find((g) => g.key === expanded.value) || null)
watch(open, (o) => {
if (o) expanded.value = selected.value?.type || (groups.value.length === 1 ? groups.value[0].key : '')
})
function toggle(k) {
expanded.value = expanded.value === k ? '' : k
}
function pick(id) {
emit('update:modelValue', id)
open.value = false
}
</script>
<template>
<button
type="button" class="ap-trigger" :class="{ empty: isEmptyState }"
:disabled="disabled" @click="open = true"
>
<span class="ap-label">{{ triggerLabel }}</span>
<span class="ap-caret"></span>
</button>
<BottomSheet v-model="open" :title="title">
<div v-if="groups.length" class="ap-picker">
<div class="ap-grid">
<button
v-if="allLabel"
type="button" class="ap-chip all" :class="{ active: !modelValue }"
@click="pick('')"
>{{ allLabel }}</button>
<button
v-for="g in groups" :key="g.key"
type="button" class="ap-chip major" :class="{ active: expanded === g.key }"
@click="toggle(g.key)"
>{{ g.label }}</button>
</div>
<div v-if="expandedGroup" class="ap-sub-grid">
<button
v-for="w in expandedGroup.items" :key="w.id"
type="button" class="ap-chip sub" :class="{ active: w.id === modelValue }"
@click="pick(w.id)"
>{{ w.name }}</button>
</div>
</div>
<p v-else class="ap-empty">{{ emptyText }}</p>
</BottomSheet>
</template>
<style scoped>
.ap-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.ap-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ap-trigger.empty .ap-label {
opacity: 0.5;
}
.ap-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-caret {
opacity: 0.5;
font-size: 0.8rem;
flex-shrink: 0;
}
.ap-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.2rem 0 0.4rem;
}
.ap-sub-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.5rem;
margin-bottom: 0.4rem;
border-radius: 0 6px 6px 6px;
background: var(--color-background-mute);
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
}
.ap-chip {
flex: 1 1 calc(25% - 0.35rem);
min-width: 0;
padding: 0.5rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.ap-empty {
padding: 1.2rem 0.4rem;
opacity: 0.6;
text-align: center;
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<script setup>
defineProps({
modelValue: { type: Boolean, required: true },
title: { type: String, default: '' },
zIndex: { type: Number, default: 1000 },
})
defineEmits(['update:modelValue'])
</script>
<template>
<Teleport to="body">
<Transition name="am">
<div v-if="modelValue" class="am-wrap" :style="{ zIndex }">
<div class="am-panel" role="dialog" aria-modal="true">
<button class="am-close" type="button" @click="$emit('update:modelValue', false)">×</button>
<div class="am-inner">
<h2 v-if="title" class="am-title">{{ title }}</h2>
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.am-wrap {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.am-panel {
position: absolute;
inset: 0;
background: var(--color-background);
overflow-y: auto;
padding: calc(3rem + env(safe-area-inset-top)) 1.25rem calc(2rem + env(safe-area-inset-bottom));
}
.am-inner {
max-width: 460px;
margin: 0 auto;
}
.am-close {
position: absolute;
top: calc(0.5rem + env(safe-area-inset-top));
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--color-text);
z-index: 1;
}
.am-title {
margin-bottom: 1.25rem;
font-size: 1.2rem;
text-align: center;
}
/* ── 슬라이드업 애니메이션 ── */
.am-enter-active {
transition: background 0.25s ease;
}
.am-leave-active {
transition: background 0.2s ease;
}
.am-enter-from,
.am-leave-to {
background: rgba(0, 0, 0, 0);
}
.am-enter-active .am-panel {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.am-leave-active .am-panel {
transition: transform 0.22s cubic-bezier(0.4, 0, 1, 1);
}
.am-enter-from .am-panel,
.am-leave-to .am-panel {
transform: translateY(100%);
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
// 아래에서 올라오는 바텀시트(콘텐츠 높이). 선택 항목(계좌·분류 등)에 사용.
defineProps({
modelValue: { type: Boolean, required: true },
title: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<template>
<Teleport to="body">
<Transition name="bs">
<div v-if="modelValue" class="bs-wrap" @click.self="$emit('update:modelValue', false)">
<div class="bs-panel" role="dialog" aria-modal="true">
<div class="bs-grip" @click="$emit('update:modelValue', false)"></div>
<div class="bs-head">
<span class="bs-title">{{ title }}</span>
<button type="button" class="bs-close" aria-label="닫기" @click="$emit('update:modelValue', false)">×</button>
</div>
<div class="bs-body"><slot /></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.bs-wrap {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
}
.bs-panel {
width: 100%;
max-width: 560px;
margin: 0 auto;
background: var(--color-background);
border-radius: 16px 16px 0 0;
max-height: 85vh;
overflow-y: auto;
padding: 0.4rem 1.1rem calc(1rem + env(safe-area-inset-bottom, 0));
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.25);
}
.bs-grip {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-border);
margin: 0.4rem auto 0.2rem;
cursor: pointer;
}
.bs-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0 0.6rem;
}
.bs-title {
font-weight: 700;
font-size: 1rem;
}
.bs-close {
border: 0;
background: transparent;
font-size: 1.4rem;
line-height: 1;
color: var(--color-text);
cursor: pointer;
}
.bs-enter-active {
transition: background 0.2s ease;
}
.bs-leave-active {
transition: background 0.18s ease;
}
.bs-enter-from,
.bs-leave-to {
background: rgba(0, 0, 0, 0);
}
.bs-enter-active .bs-panel {
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.bs-leave-active .bs-panel {
transition: transform 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.bs-enter-from .bs-panel,
.bs-leave-to .bs-panel {
transform: translateY(100%);
}
</style>
+535
View File
@@ -0,0 +1,535 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
const props = defineProps({
modelValue: { type: String, default: '' },
type: { type: String, required: true },
categories: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
admin: { type: Boolean, default: false },
reorderable: { type: Boolean, default: false },
hideAdd: { type: Boolean, default: false }, // 분류 추가(+대분류/+소분류) 버튼 숨김 (필터 등 선택 전용)
})
const emit = defineEmits(['update:modelValue', 'category-added', 'rename', 'delete', 'reorder'])
// ─── accordion state ───────────────────────────────────────────────────
const categoryMajor = ref('')
const majorOptions = computed(() =>
props.categories.filter((c) => c.type === props.type && c.parentId == null)
)
// select 모드: 4개씩 행 분할
const majorRows = computed(() => {
const rows = []
for (let i = 0; i < majorOptions.value.length; i += 4)
rows.push(majorOptions.value.slice(i, i + 4))
return rows
})
// admin 일반 모드: 2개씩 행 분할 (이름+아이콘 공간 확보)
const adminRows = computed(() => {
const rows = []
for (let i = 0; i < majorOptions.value.length; i += 2)
rows.push(majorOptions.value.slice(i, i + 2))
return rows
})
const selectedMajorObj = computed(() =>
majorOptions.value.find((m) => m.name === categoryMajor.value) ?? null
)
function subsByMajor(majorId) {
return props.categories.filter((c) => c.parentId === majorId)
}
// select 모드: 클릭 시 accordion 토글 + 값 emit
function onMajorClick(major) {
if (categoryMajor.value === major.name) {
categoryMajor.value = ''
emit('update:modelValue', '')
} else {
categoryMajor.value = major.name
if (!subsByMajor(major.id).length) emit('update:modelValue', major.name)
else emit('update:modelValue', '')
}
}
// admin 모드: 클릭 시 accordion 토글만 (값 emit 없음)
function onAdminMajorClick(major) {
categoryMajor.value = categoryMajor.value === major.name ? '' : major.name
}
// modelValue / type / categories 변경 시 대분류 상태 동기화
function syncFromValue() {
if (props.admin) return
const cur = props.modelValue
if (!cur) { categoryMajor.value = ''; return }
const c = props.categories.find((x) => x.type === props.type && x.name === cur)
if (c && c.parentId != null) {
const parent = props.categories.find((x) => x.id === c.parentId)
categoryMajor.value = parent ? parent.name : cur
} else {
categoryMajor.value = cur
}
}
watch(() => [props.modelValue, props.type, props.categories], syncFromValue, { immediate: true })
// ─── 인라인 분류 추가 ──────────────────────────────────────────────────
const addingCategory = ref(false)
const addingAsMajor = ref(true)
const newCategoryName = ref('')
const catSubmitting = ref(false)
function startAddMajor() {
if (!props.admin) { categoryMajor.value = ''; emit('update:modelValue', '') }
newCategoryName.value = ''
addingAsMajor.value = true
addingCategory.value = true
}
function startAddSub() {
newCategoryName.value = ''
addingAsMajor.value = false
addingCategory.value = true
}
function cancelAdd() {
addingCategory.value = false
newCategoryName.value = ''
}
async function confirmAdd() {
const name = newCategoryName.value.trim()
if (!name) return
catSubmitting.value = true
const parent = props.categories.find(
(c) => c.type === props.type && c.parentId == null && c.name === categoryMajor.value,
)
const parentId = parent ? parent.id : null
try {
await accountApi.createCategory({ type: props.type, name, parentId })
emit('category-added')
if (!props.admin) {
emit('update:modelValue', name)
if (!parentId) categoryMajor.value = name
} else {
if (!parentId) categoryMajor.value = name
}
cancelAdd()
} catch (e) {
if (e.response?.status === 409) {
if (!props.admin) emit('update:modelValue', name)
syncFromValue()
cancelAdd()
} else {
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
}
} finally {
catSubmitting.value = false
}
}
// ─── Admin: 인라인 이름 수정 ───────────────────────────────────────────
const editingId = ref(null)
const editingName = ref('')
const editingCategory = ref(null)
function startEdit(c) {
editingId.value = c.id
editingName.value = c.name
editingCategory.value = c
nextTick(() => {
const input = document.querySelector('.adm-edit-input')
if (input) input.focus()
})
}
function cancelEdit() {
editingId.value = null
editingName.value = ''
editingCategory.value = null
}
function confirmEdit() {
const name = editingName.value.trim()
if (name && editingCategory.value && name !== editingCategory.value.name) {
emit('rename', editingCategory.value, name)
}
cancelEdit()
}
// ─── Admin: SortableJS (순서변경 모드에서만 활성) ─────────────────────
const majorGridEl = ref(null)
const subGridEl = ref(null)
let majorSortable = null
let subSortable = null
function destroySortables() {
majorSortable?.destroy(); majorSortable = null
subSortable?.destroy(); subSortable = null
}
function initMajorSortable() {
if (!props.admin || !props.reorderable || !majorGridEl.value) return
majorSortable?.destroy()
majorSortable = Sortable.create(majorGridEl.value, {
handle: '.adm-handle',
draggable: '.admin-chip',
animation: 150,
onEnd: () => {
const ids = [...majorGridEl.value.querySelectorAll(':scope > .admin-chip')]
.map((el) => Number(el.dataset.id))
emit('reorder', ids)
},
})
}
function initSubSortable() {
if (!props.admin || !props.reorderable || !subGridEl.value) return
subSortable?.destroy()
subSortable = Sortable.create(subGridEl.value, {
handle: '.adm-handle',
draggable: '.admin-chip',
animation: 150,
onEnd: () => {
const ids = [...subGridEl.value.querySelectorAll(':scope > .admin-chip')]
.map((el) => Number(el.dataset.id))
if (ids.length) emit('reorder', ids)
},
})
}
watch(majorGridEl, (el) => { if (el && props.admin && props.reorderable) initMajorSortable() })
watch(subGridEl, (el) => { if (el && props.admin && props.reorderable) initSubSortable() })
watch(
() => [props.admin, props.reorderable, props.categories],
async () => {
if (!props.admin || !props.reorderable) { destroySortables(); return }
await nextTick()
initMajorSortable()
initSubSortable()
},
)
onBeforeUnmount(destroySortables)
</script>
<template>
<!-- SELECT MODE -->
<template v-if="!admin">
<div v-if="!addingCategory" class="cat-picker">
<template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
<div class="acc-major-grid">
<button
v-for="m in row" :key="m.id"
type="button" class="acc-chip major"
:class="{ active: categoryMajor === m.name }"
:disabled="disabled"
@click="onMajorClick(m)"
>{{ m.name }}</button>
</div>
<div
v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id)"
class="acc-sub-grid"
>
<button
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
type="button" class="acc-chip sub"
:class="{ active: modelValue === s.name }"
:disabled="disabled"
@click="$emit('update:modelValue', s.name)"
>{{ s.name }}</button>
<button v-if="!hideAdd" type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button>
</div>
</template>
<button v-if="!hideAdd" type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button>
</div>
<div v-else class="cat-input">
<input
v-model="newCategoryName" type="text"
:placeholder="addingAsMajor
? (type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류')
: `${categoryMajor} > 새 소분류`"
:disabled="catSubmitting"
@keyup.enter.prevent="confirmAdd"
/>
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAdd">확인</button>
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAdd">취소</button>
</div>
</template>
<!-- ADMIN MODE -->
<template v-else>
<div v-if="!addingCategory" class="cat-picker">
<!-- 일반 모드: 단위, 소분류 정위치 -->
<template v-if="!reorderable">
<template v-for="(row, rowIdx) in adminRows" :key="rowIdx">
<div class="acc-major-grid">
<div
v-for="m in row" :key="m.id"
class="acc-chip major admin-chip"
:class="{ active: categoryMajor === m.name }"
@click="onAdminMajorClick(m)"
>
<template v-if="editingId === m.id">
<input
v-model="editingName" class="adm-edit-input"
@keyup.enter.stop="confirmEdit" @keyup.escape="cancelEdit" @click.stop
/>
<button type="button" class="adm-ok" @mousedown.prevent @click.stop="confirmEdit"></button>
</template>
<span v-else class="adm-label">{{ m.name }}</span>
<button v-if="editingId !== m.id" type="button" class="adm-edit-btn" title="이름 수정" @click.stop="startEdit(m)"></button>
<button type="button" class="adm-del" title="삭제" @click.stop="$emit('delete', m)"></button>
</div>
</div>
<!-- 행에 선택된 대분류가 있을 때만 바로 아래에 소분류 트레이 노출 -->
<div
v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id)"
class="acc-sub-grid adm-sub-grid"
>
<div
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
class="acc-chip sub admin-chip"
:data-id="s.id"
>
<template v-if="editingId === s.id">
<input
v-model="editingName" class="adm-edit-input"
@keyup.enter.stop="confirmEdit" @keyup.escape="cancelEdit" @click.stop
/>
<button type="button" class="adm-ok" @mousedown.prevent @click.stop="confirmEdit"></button>
</template>
<span v-else class="adm-label">{{ s.name }}</span>
<button v-if="editingId !== s.id" type="button" class="adm-edit-btn" title="이름 수정" @click.stop="startEdit(s)"></button>
<button type="button" class="adm-del" title="삭제" @click.stop="$emit('delete', s)"></button>
</div>
<button type="button" class="acc-chip add-chip sub-add" @click="startAddSub">+ 소분류</button>
</div>
</template>
<button type="button" class="acc-chip add-chip" @click="startAddMajor">+ 대분류</button>
</template>
<!-- 순서변경 모드: flat 그리드 + SortableJS -->
<template v-else>
<div class="acc-major-grid adm-grid" ref="majorGridEl">
<div
v-for="m in majorOptions" :key="m.id"
class="acc-chip major admin-chip"
:class="{ active: categoryMajor === m.name }"
:data-id="m.id"
@click="onAdminMajorClick(m)"
>
<span class="adm-handle" @click.stop></span>
<span class="adm-label">{{ m.name }}</span>
</div>
</div>
<!-- 선택된 대분류의 소분류 순서 변경 -->
<div
v-if="selectedMajorObj"
class="acc-sub-grid adm-sub-grid"
ref="subGridEl"
:data-major="selectedMajorObj.id"
>
<div
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
class="acc-chip sub admin-chip"
:data-id="s.id"
>
<span class="adm-handle" @click.stop></span>
<span class="adm-label">{{ s.name }}</span>
</div>
</div>
</template>
</div>
<div v-else class="cat-input">
<input
v-model="newCategoryName" type="text"
:placeholder="addingAsMajor
? (type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류')
: `${categoryMajor} > 새 소분류`"
:disabled="catSubmitting"
@keyup.enter.prevent="confirmAdd"
/>
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAdd">확인</button>
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAdd">취소</button>
</div>
</template>
</template>
<style scoped>
.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.45rem;
border-radius: 0 6px 6px 6px;
background: var(--color-background-mute);
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
}
.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:disabled { opacity: 0.45; cursor: not-allowed; }
.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: transparent; }
.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; }
/* ── admin 칩 공통 ──────────────────────────────────────── */
/* 2개씩 한 줄 (이름+아이콘 공간 확보) */
.admin-chip {
flex: 1 1 calc(50% - 0.35rem) !important;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.32rem 0.4rem;
text-align: left;
cursor: default;
overflow: visible;
}
.adm-handle {
flex-shrink: 0;
cursor: grab;
color: var(--color-text);
opacity: 0.35;
font-size: 0.85rem;
touch-action: none;
user-select: none;
line-height: 1;
}
.adm-handle:active { cursor: grabbing; }
.adm-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.adm-edit-input {
flex: 1;
min-width: 0;
padding: 0.1rem 0.3rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.8rem;
}
.adm-ok {
flex-shrink: 0;
width: 1.3rem;
height: 1.3rem;
border: 1px solid hsla(160, 100%, 37%, 0.5);
border-radius: 3px;
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-size: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.adm-edit-btn {
flex-shrink: 0;
width: 1.3rem;
height: 1.3rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: transparent;
color: var(--color-text);
opacity: 0.45;
font-size: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.adm-edit-btn:hover { opacity: 0.9; }
.adm-del {
flex-shrink: 0;
width: 1.3rem;
height: 1.3rem;
border: 1px solid rgba(192, 57, 43, 0.3);
border-radius: 3px;
background: rgba(192, 57, 43, 0.06);
color: #c0392b;
font-size: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.adm-del:hover { background: rgba(192, 57, 43, 0.15); }
.adm-sub-grid {
gap: 0.3rem;
background: var(--color-background-mute);
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
border-radius: 0 6px 6px 6px;
padding: 0.45rem;
}
/* 소분류 칩은 트레이 배경이 보이도록 투명 처리 */
.adm-sub-grid .admin-chip {
background: transparent;
}
/* ── 인라인 추가 입력 ──────────────────────────────────── */
.cat-input {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.cat-input input {
flex: 1;
min-width: 0;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.85rem;
}
.cat-add-btn {
padding: 0.38rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.cat-add-btn.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.cat-add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup>
// select 대체 칩 선택기. 균일한 박스 그리드로 정렬(계좌 등 선택지가 적을 때 드롭다운 대신).
// 사용: <ChipSelect v-model="form.walletId" :options="[{value,label}]" :disabled="..." />
defineProps({
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] }, // [{ value, label }]
disabled: { type: Boolean, default: false },
emptyText: { type: String, default: '선택할 항목이 없습니다' },
})
const emit = defineEmits(['update:modelValue'])
function pick(v, disabled) {
if (disabled) return
emit('update:modelValue', v)
}
</script>
<template>
<div class="chip-select">
<button
v-for="o in options"
:key="String(o.value)"
type="button"
class="cs-chip"
:class="{ active: modelValue === o.value }"
:disabled="disabled"
:title="o.label"
@click="pick(o.value, disabled)"
>
{{ o.label }}
</button>
<span v-if="!options.length" class="cs-empty">{{ emptyText }}</span>
</div>
</template>
<style scoped>
/* 계좌 선택 영역: 배경 패널 안에 칩 3개씩 균일 정렬 */
.chip-select {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
min-width: 0; /* 긴 계좌명이 그리드를 부모보다 넓게 밀어 가로 오버플로우 나는 것 방지 */
box-sizing: border-box;
background: rgba(127, 127, 127, 0.08);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.5rem;
}
.cs-chip {
min-width: 0; /* 그리드 아이템 기본 min-width:auto(=글자너비) 해제 → 칸이 줄고 긴 이름은 …로 */
padding: 0.5rem 0.5rem;
border: 1px solid var(--color-border-hover);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
border-color 0.12s,
background 0.12s,
color 0.12s;
}
.cs-chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.cs-chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 700;
}
.cs-empty {
grid-column: 1 / -1;
font-size: 0.82rem;
opacity: 0.55;
padding: 0.28rem 0;
}
</style>
+1
View File
@@ -35,6 +35,7 @@ const ICONS = {
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
save: ['M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z', 'M17 21v-8H7v8', 'M7 3v5h8'],
lock: ['M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z', 'M8 11V7a4 4 0 0 1 8 0v4'],
sparkle: ['M12 4l1.6 6.4L20 12l-6.4 1.6L12 20l-1.6-6.4L4 12l6.4-1.6z'],
}
const paths = computed(() => ICONS[props.icon] || ICONS.plus)
+111
View File
@@ -0,0 +1,111 @@
<script setup>
// 필드형 트리거를 누르면 바텀시트로 옵션을 보여주고 단일 선택. (계좌·카드 선택 등)
import { computed, ref } from 'vue'
import BottomSheet from './BottomSheet.vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] }, // [{ value, label }]
placeholder: { type: String, default: '선택하세요' },
title: { type: String, default: '선택' },
disabled: { type: Boolean, default: false },
emptyText: { type: String, default: '선택할 항목이 없습니다' },
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) || null)
function pick(o) {
emit('update:modelValue', o.value)
open.value = false
}
</script>
<template>
<button
type="button" class="ss-trigger" :class="{ empty: !selected }"
:disabled="disabled" @click="open = true"
>
<span class="ss-label">{{ selected ? selected.label : placeholder }}</span>
<span class="ss-caret"></span>
</button>
<BottomSheet v-model="open" :title="title">
<div v-if="options.length" class="ss-chips">
<button
v-for="o in options" :key="o.value"
type="button" class="ss-chip" :class="{ active: o.value === modelValue }"
@click="pick(o)"
>{{ o.label }}</button>
</div>
<p v-else class="ss-empty">{{ emptyText }}</p>
</BottomSheet>
</template>
<style scoped>
.ss-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.ss-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ss-trigger.empty .ss-label {
opacity: 0.5;
}
.ss-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ss-caret {
opacity: 0.5;
font-size: 0.8rem;
flex-shrink: 0;
}
/* CategoryPicker(.acc-chip)와 동일한 4열 균일 박스 그리드 */
.ss-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.2rem 0 0.4rem;
}
.ss-chip {
flex: 1 1 calc(25% - 0.35rem);
min-width: 0;
padding: 0.5rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ss-chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.ss-empty {
padding: 1.2rem 0.4rem;
opacity: 0.6;
text-align: center;
}
</style>
+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')
},
}
+11 -4
View File
@@ -2,9 +2,9 @@
// 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리)
export const SERVICE_NAME = '돈돼지 가계부'
export const EFFECTIVE_DATE = '2026-06-28'
export const EFFECTIVE_DATE = '2026-07-05'
// 개인정보 보호 문의 — 운영자 연락처(필요 시 변경)
export const CONTACT_EMAIL = 'sbsb401824@gmail.com'
export const CONTACT_EMAIL = 'dondwaeji.app@gmail.com'
export const TERMS_OF_SERVICE = `제1조 (목적)
이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
@@ -63,15 +63,22 @@ export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자
2. 수집·이용 목적
- 회원 식별 및 로그인 등 계정 관리
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
- AI 기반 편의 기능 제공(자연어 한 줄 입력, 영수증 사진 구조화, 분류·계좌 자동 추천, 이번 달 집계 기반 재무 코멘트)
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
- (향후 도입 시) 무료 회원 대상 광고 제공
- 서비스 운영, 문의 응대 및 부정 이용 방지
3. 처리위탁 및 제3자
서비스는 기능 제공을 위해 아래 사업자의 서비스를 이용하며, 필요한 범위에서 정보가 처리될 수 있습니다.
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR)
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR — AI 인식의 보조·폴백)
- Anthropic(Claude): AI 편의 기능 처리. 회원이 해당 기능을 사용할 때에 한해 아래 정보가 처리 목적 범위에서 전송됩니다.
· 자연어 한 줄 입력: 회원이 입력한 텍스트(예: "어제 스타벅스 5천원")
· 영수증 사진 구조화: 회원이 촬영·선택한 영수증 이미지
· 분류·계좌 자동 추천: 회원의 분류·계좌 '이름' 목록
· 재무 코멘트: 이번 달 집계 수치(수입·지출·예산·분류별 합계 등)로 한정하며, 개별 거래 원문은 전송하지 않습니다.
Anthropic은 API로 전송된 데이터를 AI 모델 학습에 사용하지 않습니다. API 키는 서버에만 보관되며 앱에 노출되지 않습니다.
- Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
영수증 인식·AI 편의 기능은 회원이 제공한 정보를 해당 기능 수행 목적으로만 처리하며, 결과는 폼 자동 채움·참고 코멘트 용도로 저장은 회원 확인 후 이루어집니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
4. 광고
- 현재 서비스에는 광고가 표시되지 않습니다.
+6
View File
@@ -8,6 +8,7 @@ import router from './router'
import { setupCapacitor } from './capacitor'
import { useAuthStore } from './stores/auth'
import { useDialog } from './composables/dialog'
import { reportFatal } from './composables/appError'
import { initTheme } from './theme'
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
@@ -22,6 +23,11 @@ window.alert = (message) => {
const app = createApp(App)
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
app.config.errorHandler = (err, instance, info) => {
reportFatal({ err, info })
}
app.use(createPinia())
app.use(router)
+22
View File
@@ -0,0 +1,22 @@
// 네이티브 Sign in with Apple 래퍼 (iOS 전용). 플러그인은 지연 import.
// 반환된 identity token(JWT)의 aud 는 앱 번들 ID → 백엔드 /api/auth/apple 가 JWKS 로 검증한다.
import { Capacitor } from '@capacitor/core'
// Apple 로그인은 iOS 네이티브에서만 노출한다(웹/안드로이드는 별도 흐름 필요 — 현재 미지원).
export function isNativeApple() {
return Capacitor.getPlatform() === 'ios'
}
// Sign in with Apple 수행 → { identityToken, name }.
// 이름(givenName/familyName)은 애플이 '최초 인증 시에만' 주므로 신규 가입 채움용으로 함께 반환.
export async function nativeAppleCredential() {
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
const result = await SignInWithApple.authorize({
clientId: 'kr.sblog.slimbudget.app',
redirectURI: 'https://app.sblog.kr/auth/apple/callback',
scopes: 'email name',
})
const r = result?.response || {}
const name = [r.givenName, r.familyName].filter(Boolean).join(' ').trim() || null
return { identityToken: r.identityToken || null, name }
}
+50 -1
View File
@@ -104,6 +104,12 @@ const router = createRouter({
component: () => import('../views/admin/DefaultCategoryView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/reports',
name: 'admin-reports',
component: () => import('../views/admin/ReportAdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
path: '/account',
@@ -133,6 +139,30 @@ const router = createRouter({
component: () => import('../views/account/AccountWalletView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/wallets/new',
name: 'account-wallet-new',
component: () => import('../views/account/WalletFormView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/wallets/:id(\\d+)/edit',
name: 'account-wallet-edit',
component: () => import('../views/account/WalletFormView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/wallets/:id(\\d+)',
name: 'account-wallet-detail',
component: () => import('../views/account/AccountWalletDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/repayments',
name: 'account-repayments',
component: () => import('../views/account/RepaymentScheduleView.vue'),
meta: { requiresAuth: true, requiresPremium: true },
},
{
path: '/account/categories',
name: 'account-categories',
@@ -149,7 +179,20 @@ const router = createRouter({
path: '/account/recurrings',
name: 'account-recurrings',
component: () => import('../views/account/RecurringView.vue'),
meta: { requiresAuth: true, requiresPremium: true },
// 정기결제은 무료도 2건까지 체험(초과 시 업셀) — 유료 게이트 해제
meta: { requiresAuth: true },
},
{
path: '/account/recurrings/new',
name: 'account-recurring-new',
component: () => import('../views/account/RecurringFormView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/recurrings/:id(\\d+)/edit',
name: 'account-recurring-edit',
component: () => import('../views/account/RecurringFormView.vue'),
meta: { requiresAuth: true },
},
// 설정 (앱 하단 내비게이션 → 설정)
{
@@ -170,6 +213,12 @@ const router = createRouter({
component: () => import('../views/settings/ProfileEditView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/shop',
name: 'settings-shop',
component: () => import('../views/settings/ShopView.vue'),
meta: { requiresAuth: true },
},
],
})
+9 -1
View File
@@ -77,6 +77,14 @@ export const useAuthStore = defineStore('auth', () => {
await persist()
return res
}
async function appleLogin(identityToken, name, rememberMe = true) {
const res = await authApi.appleLogin({ identityToken, name, rememberMe })
token.value = res.token
user.value = res.member
exitDemo()
await persist()
return res
}
async function signup(payload) {
return authApi.signup(payload)
@@ -123,7 +131,7 @@ export const useAuthStore = defineStore('auth', () => {
await persist()
}
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, appleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
})
function safeParse(value) {
+4 -1
View File
@@ -2,6 +2,7 @@
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
import { Capacitor } from '@capacitor/core'
import { accountApi } from '@/api/accountApi'
import { appLock } from '@/composables/appLock'
async function safe(promise) {
try {
@@ -25,6 +26,7 @@ async function saveWorkbook(XLSX, wb, name) {
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 {
/* 공유 취소 — 파일은 캐시에 저장됨 */
@@ -42,7 +44,8 @@ export async function exportBackup() {
safe(accountApi.wallets()),
safe(accountApi.categories()),
safe(accountApi.recurrings()),
safe(accountApi.budgets()),
// 예산은 월별 — 백업엔 현재 월 예산을 담는다
safe(accountApi.budgets({ year: new Date().getFullYear(), month: new Date().getMonth() + 1 })),
safe(accountApi.tags()),
safe(accountApi.quickEntries()),
])
+157
View File
@@ -0,0 +1,157 @@
// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액)
// - 카드 할부: 무이자 가정(원금만, 이자 0)
// - 대출: 상환방식별 상각 (원리금균등/원금균등/만기일시), 현재 남은 원금(balance) 기준
function addMonths(y, m, delta) {
const d = new Date(y, m - 1 + delta, 1)
return { y: d.getFullYear(), m: d.getMonth() + 1 }
}
function label(y, m) {
return `${y}.${String(m).padStart(2, '0')}`
}
// (by,bm) - (ay,am) 개월 차
function monthsBetween(ay, am, by, bm) {
return (by - ay) * 12 + (bm - am)
}
export function currentYm() {
const d = new Date()
return { y: d.getFullYear(), m: d.getMonth() + 1 }
}
// 대출 남은 상환 스케줄. 반환: { rows:[{label,principal,interest,total,balance}], partial:boolean }
export function loanSchedule(w, now = currentYm(), cap = 600) {
const r = (Number(w.loanRate) || 0) / 100 / 12
let bal = Math.abs(Number(w.balance) || 0) // 현재 남은 원금
const method = w.loanMethod || 'EQUAL_PAYMENT'
if (bal <= 0) return { rows: [], partial: false }
// 경과·남은 개월수
let elapsed = 0
if (w.loanStart) {
const [sy, sm] = String(w.loanStart).slice(0, 7).split('-').map(Number)
if (sy && sm) elapsed = Math.max(0, monthsBetween(sy, sm, now.y, now.m))
}
const nRem = w.loanMonths ? Math.max(1, Number(w.loanMonths) - elapsed) : null
const rows = []
let { y, m } = now
if (method === 'EQUAL_PRINCIPAL') {
// 원금균등: 매월 원금 고정 = 최초원금 / 총개월 (없으면 잔액/남은개월)
const principalPer =
w.loanAmount && w.loanMonths
? Math.round(Number(w.loanAmount) / Number(w.loanMonths))
: nRem
? Math.round(bal / nRem)
: bal
while (bal > 0 && rows.length < cap) {
const interest = Math.round(bal * r)
const principal = Math.min(principalPer, bal)
bal = Math.max(0, bal - principal)
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: false }
}
if (method === 'BULLET') {
// 만기일시: 매월 이자만, 마지막 달에 원금 전액
const n = nRem || 1
for (let i = 0; i < n && rows.length < cap; i++) {
const interest = Math.round(bal * r)
const isLast = i === n - 1
const principal = isLast ? bal : 0
const nb = isLast ? 0 : bal
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: nb })
bal = nb
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: !nRem }
}
// EQUAL_PAYMENT 원리금균등
// (0) 월 상환금(고정)이 입력돼 있으면 그대로 사용 → 이자=잔액×이율, 원금=상환금-이자 로 은행과 정확히 일치
const fixed = Number(w.loanPayment) || 0
if (fixed > 0) {
while (bal > 0 && rows.length < cap) {
const interest = Math.round(bal * r)
if (fixed <= interest) {
// 상환금이 이자에 못 미침(데이터 이상) → 더 진행 못함
rows.push({ label: label(y, m), principal: 0, interest, total: fixed, balance: bal })
break
}
// 마지막 회차: 남은 잔액 한 번에 정산(은행은 잔여 반올림을 마지막 납입에 합산 → 추가 회차 안 생김)
if (bal <= fixed) {
rows.push({ label: label(y, m), principal: bal, interest, total: bal + interest, balance: 0 })
break
}
const principal = fixed - interest
bal = bal - principal
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: false }
}
// (1) 남은 개월수 n 산출: 최초 상환액으로 역산(loanStart 없어도 정확) → 안되면 loanStart 경과
let n = null
if (w.loanAmount && w.loanMonths && r > 0) {
const origPay = (Number(w.loanAmount) * r) / (1 - Math.pow(1 + r, -Number(w.loanMonths)))
// bal = origPay·(1-(1+r)^-n)/r → n = -ln(1 - bal·r/origPay) / ln(1+r)
const ratio = 1 - (bal * r) / origPay
if (ratio > 0 && ratio < 1) {
n = Math.min(Number(w.loanMonths), Math.max(1, Math.ceil(-Math.log(ratio) / Math.log(1 + r) - 1e-9)))
}
}
if (n == null) n = nRem
if (n == null) {
// 개월·원금 정보 부족 → 이번 달 이자만 안내(원금 분리 불가)
const interest = Math.round(bal * r)
return {
rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }],
partial: true,
}
}
// (2) 스케줄 상환액은 '현재 잔액'을 n개월에 상각 → 원금 항상 양수, 잔액이 0으로 수렴
// (최초원금 기준을 쓰면 잔액>원금 등 데이터 불일치 시 원금이 음수가 되어 잔액이 증가함)
const payment = r > 0 ? Math.round((bal * r) / (1 - Math.pow(1 + r, -n))) : Math.round(bal / n)
for (let i = 0; i < n && bal > 0 && rows.length < cap; i++) {
const interest = Math.round(bal * r)
let principal = Math.max(0, payment - interest)
if (i === n - 1 || principal > bal) principal = bal // 마지막 회차 잔액 정산
bal = Math.max(0, bal - principal)
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: false }
}
// 카드 할부(무이자) 남은 스케줄. entry: {amount, installmentMonths, entryDate}
// 반환: { rows:[...], monthly } 또는 null(진행중 할부 아님)
export function cardInstallmentSchedule(entry, now = currentYm()) {
const N = Number(entry.installmentMonths) || 0
const amount = Math.abs(Number(entry.amount) || 0)
const date = entry.entryDate ? String(entry.entryDate).slice(0, 7) : ''
if (N < 2 || amount <= 0 || !date) return null
const [py, pm] = date.split('-').map(Number)
if (!py || !pm) return null
const monthly = Math.round(amount / N)
// 청구 회차: 구매 다음 달부터 N개월 (k = 1..N). 마지막 회차는 반올림 오차 정산.
const all = []
for (let k = 1; k <= N; k++) {
const { y, m } = addMonths(py, pm, k)
const principal = k === N ? amount - monthly * (N - 1) : monthly
all.push({ y, m, principal })
}
const remaining = all.filter((x) => monthsBetween(now.y, now.m, x.y, x.m) >= 0)
if (!remaining.length) return null
let bal = remaining.reduce((s, x) => s + x.principal, 0)
const rows = remaining.map((x) => {
bal -= x.principal
return { label: label(x.y, x.m), principal: x.principal, interest: 0, total: x.principal, balance: Math.max(0, bal) }
})
return { rows, monthly }
}
+1 -1
View File
@@ -26,7 +26,7 @@ function goLogin() {
<div class="locked">
<div class="lock-big">🔒</div>
<h2>{{ label }} 로그인 이용할 있어요</h2>
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p>
<p class="desc">둘러보기에서는 가계부 내역 · 정기결제 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div>
</template>
+252 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi'
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
import { reminders } from '@/native/reminders'
import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo'
@@ -27,6 +28,8 @@ const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const budgetTotal = ref(0)
const spentTotal = ref(0)
const entries = ref([])
// 이번 달 상환 예정(대출·카드할부) — 현재일 기준, 가계부에 상환 기록 있으면 제외
const repayDue = ref({ total: 0, count: 0, hadAny: false })
const loading = ref(false)
const error = ref(null)
@@ -39,6 +42,21 @@ const budgetPct = computed(() =>
const budgetLeft = computed(() => budgetTotal.value - spentTotal.value)
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) {
return (n ?? 0).toLocaleString('ko-KR')
}
@@ -65,6 +83,19 @@ const daysInMonth = computed(() => new Date(year, month, 0).getDate())
const firstWeekday = computed(() => new Date(year, month - 1, 1).getDay()) // 0=일
const todayDate = now.getFullYear() === year && now.getMonth() + 1 === month ? now.getDate() : 0
// ===== 오늘 기록 넛지(습관 유도) =====
const todayCount = computed(() => dayMap.value[todayDate]?.items?.length || 0)
// 오늘부터 거슬러 연속으로 기록된 일수(이번 달 데이터 기준)
const streak = computed(() => {
if (!todayDate) return 0
let s = 0
for (let d = todayDate; d >= 1; d--) {
if ((dayMap.value[d]?.items?.length || 0) > 0) s++
else break
}
return s
})
const calendarCells = computed(() => {
const cells = []
for (let i = 0; i < firstWeekday.value; i++) cells.push(null)
@@ -105,7 +136,7 @@ const shortcuts = [
{ to: '/account/stats', icon: '📊', label: '통계', desc: '차트로 보기' },
{ to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' },
{ to: '/account/budget', icon: '🎯', label: '예산 설정', desc: '예산 대비 지출' },
{ to: '/account/recurrings', icon: '🔁', label: '고정 지출', desc: '매월·매년 반복' },
{ to: '/account/recurrings', icon: '🔁', label: '정기결제', desc: '매월·매년 반복' },
{ to: '/account/categories', icon: '🗂️', label: '분류 관리', desc: '카테고리' },
]
@@ -115,17 +146,20 @@ async function load() {
error.value = null
try {
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
const [sum, nw, status, list] = await Promise.all([
const [sum, nw, status, list, ws] = await Promise.all([
accountApi.summary({ year, month }),
accountApi.netWorth(),
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
accountApi.list({ year, month }),
auth.isPremium ? accountApi.wallets() : Promise.resolve([]),
])
summary.value = sum
networth.value = nw
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
entries.value = list || []
// 상환 예정은 유료 전용 — 무료 회원은 계산·조회 생략
if (auth.isPremium) await computeRepayDue(ws || [], list || [])
// 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외)
if (reminders.isNative()) {
const t = new Date()
@@ -139,6 +173,54 @@ async function load() {
}
}
// 이번 달 상환 예정 계산: 대출 월 상환금 + 카드 할부(이번 달 회차).
// '가계부에 상환 기록(REPAYMENT) 있으면 제외' — 해당 계좌는 이번 달 이미 납입한 것으로 봄.
async function computeRepayDue(ws, monthEntries) {
const nw = currentYm()
const nowLabel = `${nw.y}.${String(nw.m).padStart(2, '0')}`
const paid = new Set(
(monthEntries || [])
.filter((e) => e.type === 'REPAYMENT' && e.toWalletId)
.map((e) => e.toWalletId),
)
let total = 0
let count = 0
let hadAny = false
// 대출: 이번 달 상환액(원금+이자)
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
const { rows } = loanSchedule(w, nw)
const row = rows.find((r) => r.label === nowLabel)
if (!row || !row.total) continue
hadAny = true
if (paid.has(w.id)) continue
total += row.total
count += 1
}
// 카드 할부: 이번 달 청구 회차 합(무이자). 카드에 이번 달 상환 기록 있으면 제외.
const cards = ws.filter((x) => x.type === 'CARD')
if (cards.length) {
const lists = await Promise.all(cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])))
cards.forEach((c, i) => {
let cardSum = 0
for (const e of (lists[i] || []).filter((e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2)) {
const sch = cardInstallmentSchedule(e, nw)
if (!sch) continue
const row = sch.rows.find((r) => r.label === nowLabel)
if (row) cardSum += row.total
}
if (cardSum <= 0) return
hadAny = true
if (paid.has(c.id)) return
total += cardSum
count += 1
})
}
repayDue.value = { total, count, hadAny }
}
// 로그인/로그아웃 전환 시 갱신
watch(() => auth.isAuthenticated, (v) => {
if (v) load()
@@ -148,6 +230,7 @@ watch(() => auth.isAuthenticated, (v) => {
budgetTotal.value = 0
spentTotal.value = 0
entries.value = []
repayDue.value = { total: 0, count: 0, hadAny: false }
activeDay.value = 0
}
})
@@ -160,12 +243,46 @@ onMounted(load)
<!-- ===== 로그인: 요약 대시보드 ===== -->
<template v-if="auth.isAuthenticated">
<header class="greet">
<h1>안녕하세요, {{ displayName }} 👋</h1>
<template v-if="loading">
<h1>{{ monthLabel }} 가계부</h1>
</template>
<template v-else-if="todayCount > 0">
<h1 v-if="streak >= 2">{{ streak }} 연속 기록 중이에요 🔥</h1>
<h1 v-else>오늘 {{ todayCount }} 기록했어요 </h1>
<p class="today">{{ monthLabel }} 가계부 요약</p>
</template>
<template v-else>
<h1>{{ displayName }}, 오늘은 아직 기록이 없어요</h1>
<RouterLink to="/account/entries" class="greet-cta">+ 지금 기록하기</RouterLink>
</template>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<!-- 신규 유저 시작하기 안내 (데이터 없을 때만) -->
<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="card">
@@ -212,6 +329,22 @@ onMounted(load)
</div>
</div>
<!-- 이번 상환 예정 (대출·카드할부, 현재일 기준 · 상환 기록 있으면 제외) -->
<div v-if="auth.isPremium && repayDue.hadAny" class="card repay-card">
<div class="card-head">
<span class="card-title">이번 상환 예정</span>
<RouterLink to="/account/repayments" class="more">상환 </RouterLink>
</div>
<div v-if="repayDue.count > 0" class="repay-body">
<span class="repay-amt expense">{{ won(repayDue.total) }}</span>
<span class="repay-sub">아직 기록되지 않은 대출·할부 {{ repayDue.count }}</span>
</div>
<div v-else class="repay-body done">
<span class="repay-amt">이번 상환 완료 </span>
<span class="repay-sub">예정된 대출·할부를 모두 기록했어요</span>
</div>
</div>
<!-- 예산 대비 지출 (유료 전용 무료 회원은 업그레이드 안내) -->
<div class="card budget-card">
<div class="card-head">
@@ -346,6 +479,101 @@ onMounted(load)
opacity: 0.65;
font-size: 0.9rem;
}
.greet-cta {
display: inline-flex;
align-items: center;
margin-top: 0.5rem;
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.88rem;
font-weight: 700;
}
/* 신규 유저 시작하기 안내 */
.getting-started {
position: relative;
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 {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -406,6 +634,27 @@ onMounted(load)
}
/* 예산 대비 지출 카드 */
.repay-card {
margin-bottom: 1.2rem;
}
.repay-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.repay-amt {
font-size: 1.5rem;
font-weight: 800;
}
.repay-body.done .repay-amt {
font-size: 1.05rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.repay-sub {
font-size: 0.82rem;
opacity: 0.65;
}
.budget-card {
margin-bottom: 1.2rem;
}
+4 -2
View File
@@ -58,7 +58,8 @@ async function buy(product) {
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
const freeFeatures = [
'가계부 내역 기록 · 조회',
'계좌 관리 (개수 제한 없음)',
'계좌 관리 (종류별 2개)',
'정기결제 (2개까지 자동 반영)',
'자주 쓰는 내역',
'월별 수입/지출 요약',
'기본 분류',
@@ -67,10 +68,11 @@ const freeFeatures = [
const premiumFeatures = [
'전체 통계 (분류별·추이·자산)',
'예산 설정',
'고정 지출 자동 반영',
'계좌 · 정기결제 무제한',
'영수증 OCR 인식',
'카드 알림 자동 인식',
'투자 · 자산 관리',
'대출 · 할부 상환표 (원금 · 이자 분리)',
'태그 · 소분류 무제한',
'데이터 백업 / 복구 (엑셀)',
'광고 제거',
+247 -18
View File
@@ -1,8 +1,11 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
@@ -24,6 +27,15 @@ const catType = ref('EXPENSE') // 분류별 파이차트 구분
const catData = ref([]) // [{category, total}]
const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
const trendData = ref([]) // [{month, netWorth}]
const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰)
const aiTips = ref([]) // AI 절약 가이드(실행 제안)
const forecast = ref(null) // 월말 예상 지출(결정적 계산, 일회성 큰 지출 제외)
const forecastNote = ref('') // 예상 지출 근거 문장
const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
const forecastLoading = ref(false)
const aiLoading = ref(false)
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -62,16 +74,66 @@ const expenseDelta = computed(() => {
return { prev, diff, pct }
})
/* ===== 이번달 예상 지출 (실제 지출 추세로 월말 추정, run-rate) =====
경과일까지 실제 지출을 일평균으로 환산해 그 달 총일수로 추정. 이번 달에만 표시. */
const expenseForecast = computed(() => {
/* ===== 월말 예상 지출 (결정적 계산 — 매 접속 동일값) =====
일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은
일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */
function robustDailyRate(days) {
if (!days.length) return { rate: 0, dropped: 0, droppedCount: 0 }
const sorted = [...days].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
// 비정기 큰 지출(대출상환·가전·여행 등)은 남은 날에 반복되지 않으므로,
// 중앙값의 3배(또는 10만원)를 넘는 '모든' 날을 하루평균 산정에서 제외(가장 큰 1건만이 아님).
const threshold = Math.max(median * 3, 100000)
const canTrim = sorted.length >= 4
const kept = []
let dropped = 0
let droppedCount = 0
for (const d of sorted) {
if (canTrim && d > threshold) {
dropped += d
droppedCount++
} else {
kept.push(d)
}
}
const rate = kept.length ? kept.reduce((a, b) => a + b, 0) / kept.length : 0
return { rate, dropped, droppedCount }
}
async function loadForecast() {
forecast.value = null
forecastNote.value = ''
forecastHint.value = ''
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
if (!isCurrent) return null // 지난/미래 달은 추정 불필요(이미 확정 또는 데이터 없음)
if (!isCurrent) return // 과거/미래 달은 추정 불필요
const totalDays = daysInMonth(year.value, month.value)
const elapsed = now.getDate()
const projected = elapsed > 0 ? Math.round((monthExpense.value / elapsed) * totalDays) : monthExpense.value
return { projected, elapsed, totalDays }
})
if (elapsed < 1) return
// 초반엔 표본이 적어 추정이 크게 튀므로 일정 경과일 전에는 값 대신 안내만 표시
if (elapsed < FORECAST_MIN_DAYS) {
forecastHint.value = `${FORECAST_MIN_DAYS}일부터 표시돼요`
return
}
forecastLoading.value = true
try {
const daily = await accountApi.budgetPeriod({ unit: 'DAY', year: year.value, month: month.value })
const map = {}
;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0))
const days = []
for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0)
const { rate, dropped, droppedCount } = robustDailyRate(days)
const remaining = totalDays - elapsed
forecast.value = Math.round(monthExpense.value + rate * remaining)
forecastNote.value =
droppedCount > 0
? `대출상환 등 비정기 큰 지출 ${droppedCount}건(합계 ${won(dropped)})은 남은 ${remaining}일 추정에서 제외`
: `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정`
} catch {
forecast.value = null
} finally {
forecastLoading.value = false
}
}
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
const bars = computed(() => {
@@ -264,6 +326,8 @@ async function load() {
monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense
prevMonthExpense.value = prevSummary.totalExpense
loadForecast() // 결정적 예상 지출(비동기)
loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음)
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
@@ -271,6 +335,36 @@ async function load() {
}
}
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
async function loadAiComment() {
aiLoading.value = true
aiNoCredit.value = false
aiBullets.value = []
aiTips.value = []
try {
const res = await accountApi.aiComment({
year: year.value,
month: month.value,
income: monthIncome.value,
expense: monthExpense.value,
prevExpense: prevMonthExpense.value,
budget: budgetTotal.value,
spent: spentTotal.value,
categories: (catData.value || []).slice(0, 8).map((c) => ({ category: c.category, total: c.total })),
})
aiBullets.value = res?.bullets || []
aiTips.value = res?.tips || []
} catch (e) {
if (e.response?.status === 402) {
aiNoCredit.value = true
}
aiBullets.value = []
aiTips.value = []
} finally {
aiLoading.value = false
}
}
function setUnit(u) {
unit.value = u
loadBar()
@@ -335,10 +429,38 @@ onMounted(async () => {
<p v-if="loading" class="msg">불러오는 중...</p>
<template v-else>
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 -->
<div class="top-grid">
<div class="panel donut-panel">
<h2>예산 대비 지출</h2>
<!-- 무료 회원 AI 크레딧 소진 안내 -->
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
<div class="ai-comment-head"> AI 코멘트</div>
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 있어요.</p>
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 </button>
</div>
<!-- AI 재무 코멘트 + 절약 가이드: 이번 집계 해석 -->
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
<div class="ai-comment-head"> 이번 AI 코멘트</div>
<div v-if="aiLoading" class="ai-loading">
<span class="ai-loading-text">집계를 분석하고 있어요</span>
<div class="ai-loading-bar"><span></span></div>
</div>
<template v-else>
<ul v-if="aiBullets.length" class="ai-comment-list">
<li v-for="(b, i) in aiBullets" :key="i">{{ b }}</li>
</ul>
<div v-if="aiTips.length" class="ai-tips">
<div class="ai-tips-head">💡 절약 가이드</div>
<ul class="ai-comment-list">
<li v-for="(t, i) in aiTips" :key="i">{{ t }}</li>
</ul>
</div>
</template>
</div>
<!-- 이번 현황 (예산 도넛 + 수입/지출 통합) -->
<div class="panel month-overview">
<h2>이번 현황</h2>
<div class="mo-grid">
<div class="mo-budget">
<div v-if="budgetTotal > 0" class="donut-wrap">
<svg viewBox="0 0 120 120" class="donut">
<circle cx="60" cy="60" :r="R" fill="none" stroke="var(--color-background-mute)" stroke-width="14" />
@@ -360,8 +482,6 @@ onMounted(async () => {
<p v-else class="empty">설정된 예산이 없습니다.</p>
</div>
<div class="panel">
<h2>수입 대비 지출</h2>
<div class="ie">
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
@@ -371,17 +491,23 @@ onMounted(async () => {
{{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
</b>
</div>
<div v-if="expenseForecast" class="ie-row forecast">
<div v-if="forecastHint" class="ie-row forecast">
<span>예상 지출 <small>{{ forecastHint }}</small></span>
<b class="pending"></b>
</div>
<div v-else-if="forecastLoading && !forecast" class="ie-row forecast">
<span>예상 지출 <small>계산 </small></span>
<b class="pending">···</b>
</div>
<div v-else-if="forecast" class="ie-row forecast">
<span>예상 지출 <small>월말 추정</small></span>
<b class="expense">{{ won(expenseForecast.projected) }}</b>
<b class="expense">{{ won(forecast) }}</b>
</div>
<div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
<p v-if="expenseForecast" class="forecast-note">
최근 추세 기준({{ expenseForecast.elapsed }}/{{ expenseForecast.totalDays }} · 현재 {{ won(monthExpense) }})<template v-if="budgetTotal > 0">, 예산 대비 {{ Math.round((expenseForecast.projected / budgetTotal) * 100) }}%</template>
</p>
<p v-if="forecastNote" class="forecast-note">{{ forecastNote }}</p>
</div>
</div>
</div>
@@ -551,6 +677,89 @@ h2 {
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
/* AI 재무 코멘트 카드 */
.ai-comment {
border: 1px solid hsla(160, 100%, 37%, 0.45);
border-radius: 12px;
background: hsla(160, 100%, 37%, 0.07);
padding: 0.9rem 1.1rem;
margin-bottom: 1rem;
}
.ai-comment-head {
font-weight: 700;
font-size: 0.95rem;
color: hsla(160, 100%, 32%, 1);
margin-bottom: 0.5rem;
}
.ai-loading-text {
font-size: 0.85rem;
opacity: 0.65;
}
.ai-loading-bar {
height: 4px;
margin-top: 0.45rem;
border-radius: 999px;
background: hsla(160, 100%, 37%, 0.15);
overflow: hidden;
}
.ai-loading-bar > span {
display: block;
width: 40%;
height: 100%;
border-radius: 999px;
background: hsla(160, 100%, 37%, 0.85);
animation: ai-indeterminate 1.1s ease-in-out infinite;
}
@keyframes ai-indeterminate {
0% { transform: translateX(-110%); }
100% { transform: translateX(360%); }
}
.ie-row .pending {
opacity: 0.4;
letter-spacing: 2px;
}
.ai-comment-list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ai-comment-list li {
font-size: 0.9rem;
line-height: 1.45;
}
.ai-tips {
margin-top: 0.7rem;
padding-top: 0.7rem;
border-top: 1px dashed hsla(160, 100%, 37%, 0.35);
}
.ai-tips-head {
font-weight: 700;
font-size: 0.88rem;
color: #b8860b;
margin-bottom: 0.4rem;
}
.ai-no-credit {
border-color: hsla(220, 80%, 60%, 0.4);
}
.ai-credit-msg {
font-size: 0.88rem;
color: var(--color-muted, #888);
line-height: 1.5;
margin: 0.4rem 0 0.7rem;
}
.ai-shop-btn {
display: inline-block;
padding: 6px 14px;
border-radius: 7px;
border: none;
background: #2563eb;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.panel-head {
display: flex;
align-items: center;
@@ -564,6 +773,26 @@ h2 {
.top-grid .panel {
margin-bottom: 1rem;
}
/* 이번 달 현황: 예산 도넛 + 수입/지출을 한 패널에 좌우로 */
.month-overview .mo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.5rem;
align-items: center;
}
.mo-budget {
min-width: 0;
}
@media (max-width: 700px) {
.month-overview .mo-grid {
grid-template-columns: 1fr;
gap: 0.9rem;
}
.mo-budget {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.9rem;
}
}
.donut-wrap {
display: flex;
align-items: center;
+51 -8
View File
@@ -1,5 +1,5 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
import { onBeforeUnmount, onMounted, ref, nextTick, watch } from 'vue'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
@@ -11,6 +11,11 @@ const tags = ref([])
const loading = ref(false)
const error = ref(null)
const newName = ref('')
const reorderMode = ref(false)
function toggleReorder() {
reorderMode.value = !reorderMode.value
}
async function load() {
loading.value = true
@@ -88,20 +93,32 @@ async function removeTag(tag) {
}
}
onMounted(async () => {
await load()
watch(reorderMode, async (on) => {
if (on) {
await nextTick()
initSortable()
} else {
if (sortable) { sortable.destroy(); sortable = null }
}
})
onMounted(load)
onBeforeUnmount(() => sortable?.destroy())
</script>
<template>
<section class="tag-admin">
<div class="top-row">
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
<button v-if="tags.length" type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
{{ reorderMode ? '완료' : '순서변경' }}
</button>
</div>
<p v-if="reorderMode" class="hint sm"> 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b>.</p>
<form class="new-tag" @submit.prevent="addTag">
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 고정지출)" />
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 정기결제)" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form>
@@ -110,10 +127,10 @@ onBeforeUnmount(() => sortable?.destroy())
<ul v-show="!loading && tags.length" ref="listEl" class="tag-list">
<li v-for="t in tags" :key="t.id" :data-id="t.id" class="tag-row">
<span class="drag-handle" title="드래그하여 순서 변경"></span>
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" />
<IconBtn icon="check" title="저장" @click="saveTag(t)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
<span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경"></span>
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" :disabled="reorderMode" />
<IconBtn v-if="!reorderMode" icon="check" title="저장" @click="saveTag(t)" />
<IconBtn v-if="!reorderMode" icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
</li>
</ul>
<p v-if="!loading && !tags.length" class="msg">등록된 태그가 없습니다.</p>
@@ -133,11 +150,37 @@ onBeforeUnmount(() => sortable?.destroy())
h1 {
font-size: 1.5rem;
}
.top-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
.hint.sm {
margin-top: -0.5rem;
margin-bottom: 0.75rem;
}
.text-btn {
flex-shrink: 0;
font-size: 0.85rem;
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
}
.text-btn.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const walletId = Number(route.params.id)
const wallet = ref(null)
const entries = ref([])
const loading = ref(false)
const error = ref(null)
const month = ref('') // YYYY-MM
const TYPE_LABEL = {
BANK: '은행계좌',
CASH: '현금',
CARD: '신용/체크카드',
LOAN: '대출',
MINUS: '마이너스통장',
INVEST: '투자',
}
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function currentYm() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function ymLabel(ym) {
return ym ? ym.replace('-', '.') : ''
}
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
// (+/, -/)
function effectAmount(e) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e) {
const m = e.memo ? ` · ${e.memo}` : ''
const w = wallet.value
if (e.type === 'TRANSFER') {
if (e.toWalletId === walletId) {
const inLabel = w && isLiability(w.type) ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
const monthListArr = computed(() => {
const set = new Set(entries.value.map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
return [...set].sort()
})
const monthEntries = computed(() =>
entries.value.filter((e) => (e.entryDate || '').slice(0, 7) === month.value),
)
const monthSum = computed(() => monthEntries.value.reduce((s, e) => s + effectAmount(e), 0))
const canPrev = computed(() => monthListArr.value.indexOf(month.value) > 0)
const canNext = computed(() => {
const i = monthListArr.value.indexOf(month.value)
return i >= 0 && i < monthListArr.value.length - 1
})
function shiftMonth(delta) {
const ms = monthListArr.value
if (!ms.length) return
const i = ms.indexOf(month.value)
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
month.value = ms[j]
}
async function load() {
loading.value = true
error.value = null
try {
const ws = await accountApi.wallets()
wallet.value = ws.find((w) => w.id === walletId) || null
if (!wallet.value) {
error.value = '계좌를 찾을 수 없습니다.'
return
}
if (wallet.value.type !== 'INVEST') {
entries.value = await accountApi.walletEntries(walletId)
const ms = monthListArr.value
month.value = ms.length ? ms[ms.length - 1] : currentYm()
}
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section class="wdetail">
<header class="wd-head">
<span class="wd-title">{{ wallet?.name || '계좌' }}</span>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-else-if="loading" class="msg">불러오는 중...</p>
<template v-else-if="wallet">
<!-- 계좌 요약 카드 -->
<div class="wd-summary">
<div class="wd-sum-top">
<span class="wd-type">{{ TYPE_LABEL[wallet.type] }}</span>
<span v-if="wallet.type === 'CARD' && wallet.cardType" class="wd-badge">{{ cardTypeLabel(wallet.cardType) }}</span>
</div>
<div class="wd-figure" :class="{ neg: wallet.type === 'INVEST' ? false : wallet.balance < 0 }">
<template v-if="wallet.type === 'INVEST'">투자금 {{ won(wallet.investedAmount) }}</template>
<template v-else>{{ won(wallet.balance) }}</template>
</div>
<div class="wd-meta">
<span v-if="wallet.issuer">{{ wallet.issuer }}</span>
<span v-if="wallet.type === 'LOAN' && wallet.loanRate">
· {{ wallet.loanRate }}%<template v-if="wallet.loanMethod"> / {{ loanMethodLabel(wallet.loanMethod) }}</template>
</span>
</div>
</div>
<!-- 투자: 내역 대신 안내 -->
<div v-if="wallet.type === 'INVEST'" class="wd-invest-note">
<p>투자금(투입원금) 표시합니다. 금액 변경은 계좌 관리에서 <b>수정</b>하세요.</p>
</div>
<!-- : 월별 내역 -->
<template v-else>
<div v-if="monthListArr.length" class="wd-month-nav">
<button type="button" class="mn-btn" :disabled="!canPrev" aria-label="이전 " @click="shiftMonth(-1)"></button>
<span class="mn-label">{{ ymLabel(month) }}</span>
<button type="button" class="mn-btn" :disabled="!canNext" aria-label="다음 " @click="shiftMonth(1)"></button>
<span class="mn-sum" :class="monthSum < 0 ? 'neg' : 'pos'">
합계 {{ monthSum >= 0 ? '+' : '' }}{{ won(monthSum) }}
</span>
</div>
<ul v-if="monthEntries.length" class="wd-list">
<li v-for="e in monthEntries" :key="e.id" class="wd-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e) }}</span>
<span class="d-amount" :class="effectAmount(e) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e) >= 0 ? '+' : '' }}{{ won(effectAmount(e)) }}
</span>
</li>
</ul>
<p v-else-if="monthListArr.length" class="msg"> 내역이 없습니다.</p>
<p v-else class="msg">연관 내역이 없습니다.</p>
</template>
</template>
</section>
</template>
<style scoped>
.wdetail {
max-width: 760px;
margin: 0 auto;
}
.wd-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.wd-title {
font-size: 1.2rem;
font-weight: 700;
}
.msg {
margin: 0.75rem 0;
opacity: 0.8;
}
.msg.error {
color: #c0392b;
}
.wd-summary {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.wd-sum-top {
display: flex;
align-items: center;
gap: 0.4rem;
}
.wd-type {
font-size: 0.85rem;
opacity: 0.7;
}
.wd-badge {
font-size: 0.72rem;
padding: 0.05rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.wd-figure {
font-size: 1.6rem;
font-weight: 800;
margin-top: 0.25rem;
}
.wd-figure.neg {
color: #c0392b;
}
.wd-meta {
margin-top: 0.3rem;
font-size: 0.85rem;
opacity: 0.65;
}
.wd-invest-note {
font-size: 0.9rem;
opacity: 0.75;
line-height: 1.5;
}
.wd-month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
}
.mn-btn {
border: 1px solid var(--color-border);
background: var(--color-background-soft);
border-radius: 6px;
width: 2rem;
height: 2rem;
cursor: pointer;
color: var(--color-text);
}
.mn-btn:disabled {
opacity: 0.4;
cursor: default;
}
.mn-label {
font-weight: 700;
}
.mn-sum {
margin-left: auto;
font-weight: 700;
}
.mn-sum.neg,
.d-amount.neg {
color: #c0392b;
}
.mn-sum.pos,
.d-amount.pos {
color: #2e7d32;
}
.wd-list {
list-style: none;
margin: 0;
padding: 0;
}
.wd-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.d-date {
flex: 0 0 auto;
font-size: 0.82rem;
opacity: 0.6;
width: 3rem;
}
.d-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.d-amount {
flex: 0 0 auto;
font-weight: 600;
font-size: 0.9rem;
}
</style>
+195 -397
View File
@@ -1,122 +1,85 @@
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue'
const dialog = useDialog()
const auth = useAuthStore()
const router = useRouter()
const TABS = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CASH', label: '현금' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false)
const error = ref(null)
const activeType = ref('BANK')
// () (· ),
const reorderMode = ref(false)
// ( ) TABS ,
const groups = computed(() =>
TABS.map((t) => ({ ...t, wallets: wallets.value.filter((w) => w.type === t.key) })).filter(
(g) => g.wallets.length,
),
)
const formOpen = ref(false)
const editId = ref(null)
// openingBalance ( ). .
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
const submitting = ref(false)
const formError = ref(null)
// ( )
const expandedId = ref(null)
const entriesByWallet = reactive({})
const loadingEntries = ref(false)
const entryMonth = reactive({}) // (YYYY-MM)
function currentYm() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
// ( )
function goDetail(w) {
router.push(`/account/wallets/${w.id}`)
}
function ymLabel(ym) {
return ym ? ym.replace('-', '.') : ''
// / ( )
function openCreate() {
router.push('/account/wallets/new')
}
// ()
function monthList(w) {
const set = new Set((entriesByWallet[w.id] || []).map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
return [...set].sort()
}
function monthEntries(w) {
const ym = entryMonth[w.id]
return (entriesByWallet[w.id] || []).filter((e) => (e.entryDate || '').slice(0, 7) === ym)
}
function monthSum(w) {
return monthEntries(w).reduce((s, e) => s + effectAmount(e, w.id), 0)
}
function canPrev(w) {
return monthList(w).indexOf(entryMonth[w.id]) > 0
}
function canNext(w) {
const ms = monthList(w)
const i = ms.indexOf(entryMonth[w.id])
return i >= 0 && i < ms.length - 1
}
// ( )
function shiftMonth(w, delta) {
const ms = monthList(w)
if (!ms.length) return
const i = ms.indexOf(entryMonth[w.id])
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
entryMonth[w.id] = ms[j]
function openEdit(w) {
router.push(`/account/wallets/${w.id}/edit`)
}
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
// (activeType) ( sort_order )
const rows = ref([])
function syncRows() {
rows.value = wallets.value.filter((w) => w.type === activeType.value)
// ===== ( SortableJS, ) =====
let sortables = []
function destroySortables() {
sortables.forEach((s) => s.destroy())
sortables = []
}
watch([wallets, activeType], syncRows, { immediate: true })
// ( )
function selectTab(key) {
activeType.value = key
}
// ===== (SortableJS, ) =====
const listEl = ref(null)
let sortable = null
function initSortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
if (!listEl.value) return
sortable = Sortable.create(listEl.value, {
function initSortables() {
destroySortables()
if (!reorderMode.value) return //
document.querySelectorAll('.wallet-group-list').forEach((el) => {
sortables.push(
Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return
const arr = rows.value
const moved = arr.splice(evt.oldIndex, 1)[0]
arr.splice(evt.newIndex, 0, moved)
persistOrder(arr.map((r) => r.id))
const type = el.dataset.type
const ids = [...el.querySelectorAll(':scope > .wallet-item')].map((li) => Number(li.dataset.id))
// : Sortable DOM (Vue )
const byId = new Map(wallets.value.map((w) => [w.id, w]))
const moved = ids.map((id) => byId.get(id)).filter(Boolean)
wallets.value = [...moved, ...wallets.value.filter((w) => w.type !== type)]
persistOrder(type, ids)
},
}),
)
})
}
async function persistOrder(ids) {
// /
watch(reorderMode, async () => {
await nextTick()
initSortables()
})
async function persistOrder(type, ids) {
try {
await accountApi.reorderWallets(activeType.value, ids)
await accountApi.reorderWallets(type, ids)
await load()
} catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
@@ -124,52 +87,6 @@ async function persistOrder(ids) {
}
}
async function toggleExpand(w) {
if (expandedId.value === w.id) {
expandedId.value = null
return
}
expandedId.value = w.id
if (w.type === 'INVEST') return //
loadingEntries.value = true
try {
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
// = ( )
const ms = monthList(w)
entryMonth[w.id] = ms.length ? ms[ms.length - 1] : currentYm()
} catch {
entriesByWallet[w.id] = []
} finally {
loadingEntries.value = false
}
}
// (+/, -/)
function effectAmount(e, walletId) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e, w) {
const m = e.memo ? ` · ${e.memo}` : ''
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// : /, (·)
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
@@ -192,18 +109,6 @@ function toggleReveal(id) {
s.has(id) ? s.delete(id) : s.add(id)
revealedAccts.value = s
}
function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)'
}
// (%)
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() {
loading.value = true
error.value = null
@@ -216,72 +121,9 @@ async function load() {
} finally {
loading.value = false
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
type: activeType.value,
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// ( )
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// ( )
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
//
await nextTick()
initSortables()
}
async function remove(w) {
@@ -297,13 +139,12 @@ async function remove(w) {
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
onMounted(async () => {
await load()
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
onMounted(load)
onBeforeUnmount(destroySortables)
</script>
<template>
@@ -325,35 +166,37 @@ onBeforeUnmount(() => sortable?.destroy())
</div>
</div>
<div class="tabbar">
<div class="tabs">
<div class="wallet-head">
<button
v-for="t in TABS"
:key="t.key"
type="button"
:class="{ active: activeType === t.key }"
@click="selectTab(t.key)"
>{{ t.label }}</button>
</div>
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
v-if="wallets.length > 1"
type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }"
@click="reorderMode = !reorderMode"
>{{ reorderMode ? '완료' : '순서변경' }}</button>
<IconBtn class="tab-add" icon="plus" title="계좌 추가" variant="primary" @click="openCreate" />
</div>
<p v-if="reorderMode" class="msg reorder-hint"> 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b> 누르세요.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && rows.length" ref="listEl" class="wallet-list">
<li v-for="w in rows" :key="w.id" :data-id="w.id" class="wallet-item">
<div class="wallet-row" @click="toggleExpand(w)">
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop></span>
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
<template v-if="!loading && groups.length">
<section v-for="g in groups" :key="g.key" class="wallet-group">
<h3 class="wg-title">{{ g.label }}</h3>
<ul class="wallet-list wallet-group-list" :data-type="g.key">
<li v-for="w in g.wallets" :key="w.id" :data-id="w.id" class="wallet-item">
<div class="wallet-row">
<span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경"></span>
<div class="info">
<div class="line1">
<span class="name">{{ w.name }}</span>
<span
class="name" :class="{ link: !reorderMode && w.type !== 'INVEST' }"
@click="!reorderMode && w.type !== 'INVEST' && goDetail(w)"
>{{ w.name }}</span>
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div>
<span class="sub">
{{ w.issuer }}
<template v-if="w.type === 'BANK' && w.accountNumber">
<template v-if="(w.type === 'BANK' || w.type === 'MINUS') && w.accountNumber">
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
<button
type="button" class="acct-eye"
@@ -361,109 +204,33 @@ onBeforeUnmount(() => sortable?.destroy())
@click.stop="toggleReveal(w.id)"
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span>
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
</template>
</span>
</div>
<div class="balance-wrap">
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
<div
v-if="w.type === 'INVEST' && w.valuationGain != null"
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
>
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
<div v-if="w.type === 'INVEST'" class="balance">{{ won(w.investedAmount) }}</div>
<div v-else class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
</div>
</div>
<div class="actions" @click.stop>
<div v-if="!reorderMode" class="actions">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div>
</div>
<!-- 드롭다운: 투자는 투자금·평가액 요약, 외는 계좌별 내역 -->
<div v-if="expandedId === w.id" class="entry-drop">
<div v-if="w.type === 'INVEST'" class="manual-note">
<div class="invest-figs">
<div class="fig"><span class="fig-k">투자금</span><span class="fig-v">{{ won(w.investedAmount) }}</span></div>
<div class="fig"><span class="fig-k">평가액</span><span class="fig-v">{{ won(w.balance) }}</span></div>
<div class="fig"><span class="fig-k">손익</span>
<span class="fig-v" :class="(w.valuationGain || 0) < 0 ? 'neg' : 'pos'">
{{ (w.valuationGain || 0) >= 0 ? '+' : '' }}{{ won(w.valuationGain || 0) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</span>
</div>
</div>
<p class="manual-sub">평가액을 갱신하려면 <b>수정</b>에서 현재 평가액 바꾸세요.</p>
</div>
<template v-else>
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
<template v-else-if="(entriesByWallet[w.id] || []).length">
<!-- 네비게이션 + 합계 -->
<div class="month-nav">
<button type="button" class="mn-btn" :disabled="!canPrev(w)" aria-label="이전 " @click="shiftMonth(w, -1)"></button>
<span class="mn-label">{{ ymLabel(entryMonth[w.id]) }}</span>
<button type="button" class="mn-btn" :disabled="!canNext(w)" aria-label="다음 " @click="shiftMonth(w, 1)"></button>
<span class="mn-sum" :class="monthSum(w) < 0 ? 'neg' : 'pos'">
합계 {{ monthSum(w) >= 0 ? '+' : '' }}{{ won(monthSum(w)) }}
</span>
</div>
<ul v-if="monthEntries(w).length" class="drop-list">
<li v-for="e in monthEntries(w)" :key="e.id" class="drop-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e, w) }}</span>
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
</span>
</li>
</ul>
<p v-else class="drop-msg"> 내역이 없습니다.</p>
</section>
</template>
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
</template>
</div>
</li>
</ul>
<p v-if="!loading && !rows.length" class="msg">등록된 항목이 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label v-if="form.type === 'CARD'">카드 종류
<select v-model="form.cardType" :disabled="submitting">
<option value="CREDIT">신용카드</option>
<option value="CHECK">체크카드</option>
</select>
</label>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
</label>
<p class="form-hint">
<b>투자금</b>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
<div 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>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
@@ -529,42 +296,46 @@ button.primary {
.nw-card .value.debt {
color: #c0392b;
}
.tabbar {
.wallet-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
flex: 1;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* 한 줄 유지(넘치면 가로 스크롤), 스크롤바 숨김 */
}
.tabs::-webkit-scrollbar {
display: none;
margin-bottom: 0.75rem;
}
.tab-add {
flex-shrink: 0;
margin-left: auto;
}
.tabs button {
.text-btn {
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
padding: 0.6rem 0.7rem;
white-space: nowrap; /* 버튼 안 글자가 줄바꿈되지 않도록(두 줄 방지) */
flex-shrink: 0;
color: var(--color-text);
opacity: 0.75;
font-size: 0.88rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
.reorder-btn.active {
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
font-weight: 700;
opacity: 1;
}
.reorder-hint {
font-size: 0.82rem;
opacity: 0.7;
margin: 0 0 0.6rem;
}
.wallet-group {
margin-bottom: 1.4rem;
}
.wg-title {
font-size: 0.9rem;
font-weight: 700;
opacity: 0.7;
margin: 0 0 0.35rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--color-border);
}
.wallet-list {
list-style: none;
@@ -579,10 +350,6 @@ button.primary {
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
}
.drag-handle {
cursor: grab;
@@ -736,6 +503,13 @@ button.primary {
.name {
font-weight: 600;
}
.name.link {
cursor: pointer;
color: hsla(160, 100%, 37%, 1);
}
.name.link:active {
opacity: 0.7;
}
.badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
@@ -795,47 +569,47 @@ button.primary {
.msg {
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 {
color: #c0392b;
}
.msg.wallet-limit {
color: #b8860b;
font-size: 0.85rem;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.wallet-form {
display: flex;
flex-direction: column;
@@ -855,21 +629,45 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
/* 배지형 선택 */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.wallet {
max-width: 100%;
+292 -189
View File
@@ -1,10 +1,15 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import BottomSheet from '@/components/ui/BottomSheet.vue'
const dialog = useDialog()
const route = useRoute()
const router = useRouter()
const now = new Date()
const year = ref(now.getFullYear())
@@ -15,20 +20,28 @@ const budgetsRaw = ref([])
const loading = ref(false)
const error = ref(null)
// (?budget) ( )
const formOpen = ref(false)
function openForm() {
formOpen.value = true
if (route.query.budget == null) router.push({ query: { ...route.query, budget: '1' } })
}
function closeForm() {
formOpen.value = false
if (route.query.budget != null) router.back()
}
watch(() => route.query.budget, (v) => { if (v == null) formOpen.value = false })
const editId = ref(null)
const form = reactive({
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
const submitting = ref(false)
const formError = ref(null)
//
const catSheet = ref(false)
watch(() => form.category, (v) => { if (v) catSheet.value = false })
// ()
const categories = ref([])
@@ -39,53 +52,11 @@ async function loadCategories() {
categories.value = []
}
}
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
// (=optgroup / = )
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === 'EXPENSE')
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
return groups
})
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate()
}
function daysInYear(y) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
}
function baseDays(unit) {
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
}
// ( / )
const fixedPreview = computed(() => {
if (!form.fixed) return null
const amount = Number(form.baseAmount) || 0
const daily = amount / baseDays(form.baseUnit)
return {
weekly: Math.round(daily * 7),
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
yearly: Math.round(daily * daysInYear(year.value)),
}
})
function ratio(s) {
if (!s.monthlyBudget) return 0
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
@@ -127,7 +98,7 @@ async function load() {
error.value = null
try {
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
budgetsRaw.value = raw
} catch (e) {
@@ -154,37 +125,59 @@ function nextMonth() {
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() {
editId.value = null
Object.assign(form, {
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
Object.assign(form, { category: '', monthly: null })
formError.value = null
formOpen.value = true
openForm()
}
function openEdit(s) {
const b = budgetsRaw.value.find((x) => x.id === s.id)
if (!b) return
editId.value = b.id
Object.assign(form, {
category: b.category,
fixed: b.fixed,
baseUnit: b.baseUnit || 'DAY',
baseAmount: b.baseAmount,
daily: b.daily,
weekly: b.weekly,
monthly: b.monthly,
yearly: b.yearly,
})
Object.assign(form, { category: b.category, monthly: b.monthly })
formError.value = null
formOpen.value = true
openForm()
}
async function submit() {
@@ -193,28 +186,23 @@ async function submit() {
formError.value = '카테고리를 입력하세요.'
return
}
let payload
if (form.fixed) {
if (!form.baseAmount || form.baseAmount <= 0) {
formError.value = '기준 금액을 입력하세요.'
if (!form.monthly || form.monthly <= 0) {
formError.value = '월 예산 금액을 입력하세요.'
return
}
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
} else {
payload = {
const payload = {
category: form.category.trim(),
fixed: false,
daily: form.daily != null ? Number(form.daily) : null,
weekly: form.weekly != null ? Number(form.weekly) : null,
monthly: form.monthly != null ? Number(form.monthly) : null,
yearly: form.yearly != null ? Number(form.yearly) : null,
}
daily: null,
weekly: null,
monthly: Number(form.monthly),
yearly: null,
}
submitting.value = true
try {
if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload)
formOpen.value = false
else await accountApi.createBudget(payload, { year: year.value, month: month.value })
closeForm()
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
@@ -242,6 +230,7 @@ onMounted(() => {
<template>
<section class="budget">
<template v-if="!formOpen">
<div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
@@ -252,6 +241,12 @@ onMounted(() => {
</div>
</div>
<!-- 월별 예산 복사 -->
<div class="copy-actions">
<button type="button" class="copy-btn" :disabled="copying" @click="copyFromPrevMonth"> 전월 예산 가져오기</button>
<button type="button" class="copy-btn" :disabled="copying || !budgetsRaw.length" @click="copyToNextMonth">다음 달로 복사 </button>
</div>
<!-- 예상 수입 vs 예산 -->
<div class="income-panel">
<div class="inc-input">
@@ -302,71 +297,45 @@ onMounted(() => {
</li>
</ul>
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p>
</template>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
<!-- 예산 추가/수정 (인라인 페이지 헤더·하단바 유지, 뒤로가기로 닫힘) -->
<div v-else class="budget-page">
<header class="ep-head">
<span class="ep-title">예산 {{ editId ? '수정' : '추가' }}</span>
</header>
<form class="budget-form" @submit.prevent="submit">
<label>카테고리
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select>
</label>
<div class="mode">
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
<div class="field">
<span class="field-label">카테고리</span>
<button
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
:disabled="submitting" @click="catSheet = true"
>
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
<span class="st-caret"></span>
</button>
</div>
<BottomSheet v-model="catSheet" title="분류 선택">
<CategoryPicker
v-model="form.category"
type="EXPENSE"
:categories="categories"
:disabled="submitting"
@category-added="loadCategories"
/>
</BottomSheet>
<!-- 고정 -->
<template v-if="form.fixed">
<div class="fixed-base">
<select v-model="form.baseUnit" :disabled="submitting">
<option value="DAY"></option>
<option value="WEEK"></option>
<option value="MONTH"></option>
</select>
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
</div>
<p v-if="fixedPreview" class="preview">
환산 {{ won(fixedPreview.weekly) }} · {{ won(fixedPreview.monthly) }} · {{ won(fixedPreview.yearly) }}
<span class="note">(이번 {{ daysInMonth(year, month) }} / 올해 {{ daysInYear(year) }} 기준)</span>
</p>
</template>
<!-- 비고정 -->
<template v-else>
<label> 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
</template>
<p class="note">설정한 예산 기준으로 이번 지출과 비교됩니다.</p>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="close" title="취소" @click="closeForm()" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
@@ -375,6 +344,52 @@ onMounted(() => {
max-width: 640px;
margin: 0 auto;
}
.budget-page .budget-form {
max-width: 460px;
margin: 0 auto;
}
.ep-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.ep-title {
font-size: 1.2rem;
font-weight: 700;
}
.sheet-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.sheet-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sheet-trigger.empty .st-label {
opacity: 0.5;
}
.st-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-caret {
opacity: 0.5;
font-size: 0.8rem;
flex-shrink: 0;
}
.head {
display: flex;
align-items: center;
@@ -431,6 +446,26 @@ button.primary {
text-align: center;
}
/* 예상 수입 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 {
border: 1px solid var(--color-border);
border-radius: 8px;
@@ -581,43 +616,6 @@ button.primary {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.budget-form {
display: flex;
flex-direction: column;
@@ -637,17 +635,42 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
.mode {
/* 배지형 선택 */
.field {
display: flex;
gap: 1rem;
font-size: 0.85rem;
padding: 0.25rem 0;
flex-direction: column;
gap: 0.3rem;
}
.radio {
flex-direction: row !important;
align-items: center;
gap: 0.3rem !important;
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.field-hint {
font-size: 0.75rem;
opacity: 0.7;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.fixed-base {
display: flex;
@@ -675,12 +698,92 @@ button.primary {
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
/* 1회성 예산 */
.onetime-section {
margin-top: 2rem;
border-top: 2px solid var(--color-border);
padding-top: 1.25rem;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
.onetime-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.onetime-title {
font-size: 1rem;
font-weight: 700;
}
.onetime-list {
list-style: none;
padding: 0;
margin: 0;
}
.onetime-row {
padding: 0.85rem 0;
border-bottom: 1px solid var(--color-border);
}
.ot-top {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.ot-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ot-status {
font-size: 0.7rem;
border-radius: 3px;
padding: 0.05rem 0.35rem;
border: 1px solid;
white-space: nowrap;
}
.ot-status.active { color: #2e7d32; border-color: #2e7d32; }
.ot-status.upcoming { color: #1565c0; border-color: #1565c0; }
.ot-status.ended { opacity: 0.5; }
.ot-amounts {
font-size: 0.9rem;
white-space: nowrap;
}
.ot-amounts .neg { color: #c0392b; font-weight: 600; }
.ot-sep { opacity: 0.5; margin: 0 0.2rem; }
.ot-bottom {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.35rem;
font-size: 0.8rem;
flex-wrap: wrap;
}
.ot-date { opacity: 0.65; }
.ot-cat {
background: var(--color-background-mute);
border-radius: 3px;
padding: 0.05rem 0.35rem;
font-size: 0.75rem;
}
.ot-remain { opacity: 0.85; }
.ot-remain.neg { color: #c0392b; opacity: 1; }
.ot-empty { opacity: 0.6; }
/* 날짜 범위 입력 */
.date-range {
display: flex;
align-items: flex-end;
gap: 0.4rem;
}
.date-range label {
flex: 1;
}
.range-sep {
padding-bottom: 0.55rem;
opacity: 0.6;
font-size: 0.9rem;
}
</style>
+123 -313
View File
@@ -1,19 +1,23 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import Sortable from 'sortablejs'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
const categories = ref([])
const rows = ref([]) // (activeType)
const loading = ref(false)
const error = ref(null)
const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('')
const newParent = ref('') // ('' = )
const activeType = ref('EXPENSE')
const reorderMode = ref(false)
// ( confirm PC 'sb_pt' )
function toggleReorder() {
reorderMode.value = !reorderMode.value
}
//
watch(activeType, () => { reorderMode.value = false })
//
const confirmState = reactive({ open: false, title: '', message: '', onOk: null })
function askConfirm(title, message, onOk) {
confirmState.title = title
@@ -26,22 +30,12 @@ function confirmOk() {
confirmState.open = false
if (fn) fn()
}
// alert
function showError(e, fallback) {
error.value = e.response?.data?.message || fallback
}
// categories/activeType ( sort_order )
function syncRows() {
rows.value = categories.value.filter((c) => c.type === activeType.value)
}
watch([categories, activeType], syncRows, { immediate: true })
// (parent )
const majors = computed(() => rows.value.filter((c) => c.parentId == null))
// (major/children row v-model )
const tree = computed(() =>
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
const hasCategories = computed(() =>
categories.value.some((c) => c.type === activeType.value)
)
async function load() {
@@ -55,104 +49,45 @@ async function load() {
loading.value = false
}
}
async function reload() {
async function onRename(cat, newName) {
error.value = null
try {
await accountApi.updateCategory(cat.id, { type: cat.type, name: newName, parentId: cat.parentId || null })
await load()
await nextTick()
initSortable()
}
// ===== ( / ) =====
const majorListEl = ref(null)
let sortables = []
function destroySortables() {
sortables.forEach((s) => s.destroy())
sortables = []
}
function initSortable() {
destroySortables()
if (!majorListEl.value) return
//
sortables.push(
Sortable.create(majorListEl.value, {
handle: '.major-handle',
draggable: '.major-block',
animation: 150,
onEnd: () => {
const ids = [...majorListEl.value.querySelectorAll(':scope > .major-block')].map((li) => Number(li.dataset.id))
persistOrder(ids)
},
}),
)
// group
majorListEl.value.querySelectorAll('.sub-list').forEach((ul) => {
sortables.push(
Sortable.create(ul, {
handle: '.sub-handle',
draggable: '.sub-item',
group: 'sub-' + ul.dataset.parent,
animation: 150,
onEnd: () => {
const ids = [...ul.querySelectorAll(':scope > .sub-item')].map((li) => Number(li.dataset.id))
if (ids.length) persistOrder(ids)
},
}),
)
})
}
// ( )
function reorderLocal(ids) {
const idSet = new Set(ids)
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
let k = 0
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
syncRows()
}
async function persistOrder(ids) {
reorderLocal(ids)
try {
await accountApi.reorderCategories(activeType.value, ids)
} catch (e) {
showError(e, '순서 저장에 실패했습니다.')
}
await reload()
}
async function addCategory() {
const name = newName.value.trim()
if (!name) return
error.value = null
try {
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = ''
await reload()
} catch (e) {
showError(e, '추가에 실패했습니다.')
}
}
async function saveCategory(c) {
const name = (c.name || '').trim()
if (!name) return
error.value = null
try {
// parentId ( )
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
await reload()
} catch (e) {
showError(e, '수정에 실패했습니다.')
}
}
function removeCategory(c) {
askConfirm('분류 삭제', `'${c.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`, async () => {
function onDelete(cat) {
askConfirm(
'분류 삭제',
`'${cat.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`,
async () => {
error.value = null
try {
await accountApi.removeCategory(c.id)
await reload()
await accountApi.removeCategory(cat.id)
await load()
} catch (e) {
showError(e, '삭제에 실패했습니다.')
}
})
},
)
}
async function onReorder(ids) {
//
const idSet = new Set(ids)
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
let k = 0
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
try {
await accountApi.reorderCategories(activeType.value, ids)
} catch (e) {
showError(e, '순서 저장에 실패했습니다.')
await load()
}
}
function importDefaults() {
@@ -160,24 +95,13 @@ function importDefaults() {
error.value = null
try {
categories.value = await accountApi.importCategories()
await nextTick()
initSortable()
} catch (e) {
showError(e, '불러오기에 실패했습니다.')
}
})
}
// (/)
watch(activeType, async () => {
await nextTick()
initSortable()
})
onMounted(async () => {
await reload()
})
onBeforeUnmount(destroySortables)
onMounted(load)
</script>
<template>
@@ -189,52 +113,42 @@ onBeforeUnmount(destroySortables)
<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>
<form class="new-cat" @submit.prevent="addCategory">
<select v-model="newParent" class="parent-sel" title="대분류 선택">
<option value="">대분류로 추가</option>
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
<div class="nc-line">
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
</div>
</form>
<p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다.</p>
<p class="hint sm">
<template v-if="reorderMode"> 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b>.</template>
<template v-else> 이름 수정 &nbsp;·&nbsp; 삭제 &nbsp;·&nbsp; 대분류 클릭 소분류 펼침</template>
</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && tree.length" ref="majorListEl" class="major-list">
<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-main">
<span class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<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="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
</div>
</div>
<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">
<div class="cat-main">
<span class="drag-handle sub-handle" title="소분류 순서 변경"></span>
<span class="sub-mark"></span>
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
</div>
</li>
</ul>
</li>
</ul>
<p v-if="!loading && !tree.length" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p>
<CategoryPicker
v-if="!loading || categories.length"
:admin="true"
:reorderable="reorderMode"
:type="activeType"
:categories="categories"
@rename="onRename"
@delete="onDelete"
@reorder="onReorder"
@category-added="load"
/>
<!-- 인앱 확인 모달 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<div v-if="!loading && !hasCategories" class="empty-state">
<p class="empty-emoji">🗂</p>
<p class="empty-title">등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요</p>
<p class="empty-desc">기본 분류를 불러오면 식비·교통 등이 번에 추가됩니다.</p>
<button type="button" class="empty-cta" @click="importDefaults">기본 분류 불러오기</button>
</div>
<!-- 인앱 확인 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="confirmState.open" class="modal-backdrop" @click.self="confirmState.open = false">
@@ -257,41 +171,14 @@ onBeforeUnmount(destroySortables)
max-width: 560px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
h1 {
font-size: 1.5rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
button {
padding: 0.45rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;
}
.tabs-row {
display: flex;
@@ -299,15 +186,14 @@ button.danger {
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tabs-row .tabs {
margin-bottom: 0;
}
.tabs-row .tabs { flex: none; }
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
margin-bottom: 0;
}
.tabs button {
border: 0;
@@ -315,120 +201,62 @@ button.danger {
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
white-space: nowrap;
cursor: pointer;
color: var(--color-text);
font-size: 0.9rem;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.new-cat {
.tabs-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.nc-line {
display: flex;
gap: 0.5rem;
}
.nc-line input {
flex: 1;
min-width: 0;
}
.major-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.major-block {
.text-btn {
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.major-row {
border-radius: 6px;
background: var(--color-background-soft);
border-bottom: 0 !important;
color: var(--color-text);
padding: 0.4rem 0.75rem;
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.sub-list {
list-style: none;
padding: 0 0 0 0.4rem;
margin: 0;
.text-btn:hover { background: var(--color-background-mute); }
.reorder-btn.active {
color: #fff;
background: hsla(160, 100%, 37%, 1);
border-color: hsla(160, 100%, 37%, 1);
}
.sub-item:last-child {
border-bottom: 0;
.msg { margin: 0.75rem 0; font-size: 0.9rem; }
.msg.error { 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;
}
.cat-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.cat-main {
display: flex;
gap: 0.4rem;
align-items: center;
}
.cat-parent {
display: flex;
align-items: center;
gap: 0.4rem;
padding-left: 1.4rem;
font-size: 0.8rem;
opacity: 0.85;
}
.cat-parent .pl {
flex: 0 0 auto;
opacity: 0.6;
}
.cat-parent .parent-sel {
flex: 1;
min-width: 0;
}
.drag-handle {
cursor: grab;
user-select: none;
opacity: 0.5;
font-size: 1.1rem;
padding: 0 0.2rem;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.cat-name {
flex: 1;
min-width: 0;
}
.cat-row.child {
padding-left: 1.2rem;
}
.cat-row.child .cat-name {
background: var(--color-background-soft);
}
.sub-mark {
opacity: 0.45;
}
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
/* 인앱 확인 모달 */
.empty-cta:hover { background: hsla(160, 100%, 32%, 1); }
/* 확인 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
@@ -441,27 +269,9 @@ button.danger {
border: 1px solid var(--color-border);
border-radius: 12px;
}
.modal h2 {
font-size: 1.05rem;
margin: 0 0 0.6rem;
}
.confirm-body {
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.modal h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
.confirm-body { margin: 0 0 1rem; font-size: 0.9rem; line-height: 1.5; white-space: pre-line; }
.buttons { display: flex; justify-content: flex-end; gap: 0.5rem; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+9 -88
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
const props = defineProps({ walletId: { type: [Number, String], required: true } })
const emit = defineEmits(['changed'])
@@ -339,12 +340,7 @@ onMounted(async () => {
</ul>
<!-- 종목 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="holdingModal = false">×</button>
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
<AppModal v-model="holdingModal" :title="`종목 ${holdingEditId ? '수정' : '추가'}`">
<form class="pf-form" @submit.prevent="submitHolding">
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="예: 005930 (국내 상장)" :disabled="hSubmitting" /></label>
@@ -356,18 +352,10 @@ onMounted(async () => {
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</AppModal>
<!-- 매매 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="tradeModal = false">×</button>
<h2>{{ tradeHolding?.name }} {{ tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수') }}</h2>
<!-- 매매 모달 (매매내역 위에 표시되도록 z-index 높게) -->
<AppModal v-model="tradeModal" :title="`${tradeHolding?.name} ${tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수')}`" :zIndex="1100">
<form class="pf-form" @submit.prevent="submitTrade">
<label>구분
<select v-model="tForm.tradeType" :disabled="tSubmitting">
@@ -399,18 +387,10 @@ onMounted(async () => {
<IconBtn icon="save" :title="tradeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="tSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</AppModal>
<!-- 전체 매매내역 모달 (스크롤) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="allTradesModal" class="modal-backdrop trades-backdrop" @click.self="allTradesModal = false">
<div class="modal trades-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="allTradesModal = false">×</button>
<h2>{{ allTradesHolding?.name }} 매매내역</h2>
<!-- 전체 매매내역 모달 -->
<AppModal v-model="allTradesModal" :title="`${allTradesHolding?.name} 매매내역`" :zIndex="1050">
<ul class="trade-list modal-trades">
<li v-for="t in allTradesList" :key="t.id" class="trade-row">
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
@@ -422,10 +402,7 @@ onMounted(async () => {
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, allTradesHolding.id)" />
</li>
</ul>
</div>
</div>
</Transition>
</Teleport>
</AppModal>
</div>
</template>
@@ -608,16 +585,7 @@ onMounted(async () => {
.more-trades:hover {
border-color: var(--color-border-hover);
}
/* 전체보기 모달: 매매목록 아래 매수 모달이 위에 오도록 한 단계 낮게 */
.modal-backdrop.trades-backdrop {
z-index: 1090;
}
.trades-modal {
max-width: 420px;
}
.modal-trades {
max-height: 60vh;
overflow-y: auto;
margin-top: 0.5rem;
}
.trade-list {
@@ -662,44 +630,6 @@ onMounted(async () => {
line-height: 1;
}
/* 모달 (계좌 관리와 동일 톤) */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
text-align: center;
}
.pf-form {
display: flex;
flex-direction: column;
@@ -741,15 +671,6 @@ onMounted(async () => {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
@media (max-width: 768px) {
.hold-row {
+424
View File
@@ -0,0 +1,424 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import AccountPicker from '@/components/ui/AccountPicker.vue'
import BottomSheet from '@/components/ui/BottomSheet.vue'
const route = useRoute()
const router = useRouter()
const editId = route.params.id ? Number(route.params.id) : null
const isEdit = editId != null
const wallets = ref([])
const categories = ref([])
const loading = ref(false)
const submitting = ref(false)
const formError = ref(null)
const catSheet = ref(false)
const form = reactive({
title: '',
type: 'EXPENSE',
amount: null,
category: '',
memo: '',
walletKind: '',
walletId: '',
toWalletKind: '',
toWalletId: '',
frequency: 'MONTHLY',
dayOfMonth: 1,
dayOfWeek: 1,
monthOfYear: 1,
startDate: '',
endDate: '',
active: true,
})
// ( )
watch(() => form.category, (v) => { if (v) catSheet.value = false })
const TYPES = [
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
]
const FREQS = [
{ value: 'MONTHLY', label: '매월' },
{ value: 'YEARLY', label: '매년' },
]
const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
{ value: 'MINUS', label: '마이너스' },
]
const fromWalletKinds = computed(() =>
WALLET_KINDS.filter((k) => k.value !== 'CARD' || form.type === 'EXPENSE'),
)
const toWalletKinds = computed(() => WALLET_KINDS.filter((k) => k.value !== 'CARD'))
// / ( )
const fromWallets = computed(() =>
wallets.value.filter((w) => fromWalletKinds.value.some((k) => k.value === w.type)),
)
const toWallets = computed(() =>
wallets.value.filter((w) => toWalletKinds.value.some((k) => k.value === w.type)),
)
function onTypeChange() {
form.category = ''
//
const fw = wallets.value.find((w) => w.id === form.walletId)
if (fw && !fromWalletKinds.value.some((k) => k.value === fw.type)) form.walletId = ''
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function loadCategories() {
try {
categories.value = await accountApi.categories()
} catch {
categories.value = []
}
}
async function load() {
loading.value = true
try {
const [ws, recs] = await Promise.all([
accountApi.wallets(),
isEdit ? accountApi.recurrings() : Promise.resolve([]),
])
wallets.value = ws
await loadCategories()
if (isEdit) {
const r = recs.find((x) => x.id === editId)
if (!r) {
formError.value = '정기결제를 찾을 수 없습니다.'
return
}
Object.assign(form, {
title: r.title,
type: r.type,
amount: r.amount,
category: r.category || '',
memo: r.memo || '',
walletId: r.walletId || '',
toWalletId: r.toWalletId || '',
frequency: r.frequency,
dayOfMonth: r.dayOfMonth || 1,
dayOfWeek: r.dayOfWeek || 1,
monthOfYear: r.monthOfYear || 1,
startDate: r.startDate,
endDate: r.endDate || '',
active: r.active,
})
} else {
form.startDate = todayStr()
}
} catch (e) {
formError.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
function goBack() {
if (window.history.length > 1) router.back()
else router.replace('/account/recurrings')
}
async function submit() {
formError.value = null
if (!form.title.trim()) {
formError.value = '이름을 입력하세요.'
return
}
if (!form.amount || form.amount <= 0) {
formError.value = '금액을 입력하세요.'
return
}
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
return
}
if (!form.startDate) {
formError.value = '시작일을 입력하세요.'
return
}
submitting.value = true
const payload = {
title: form.title.trim(),
type: form.type,
amount: Number(form.amount),
category: form.type === 'TRANSFER' ? null : form.category || null,
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
frequency: form.frequency,
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
startDate: form.startDate,
endDate: form.endDate || null,
active: form.active,
}
try {
if (isEdit) await accountApi.updateRecurring(editId, payload)
else await accountApi.createRecurring(payload)
router.replace('/account/recurrings')
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="rform">
<header class="rf-head">
<span class="rf-title">정기결제 {{ isEdit ? '수정' : '추가' }}</span>
</header>
<p v-if="loading" class="msg">불러오는 중...</p>
<form v-else class="recur-form" @submit.prevent="submit">
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
<!-- 구분 배지 -->
<div class="field">
<span class="field-label">구분</span>
<div class="type-chips">
<button
v-for="t in TYPES" :key="t.value"
type="button" class="chip" :class="[t.cls, { active: form.type === t.value }]"
:disabled="submitting"
@click="form.type = t.value; onTypeChange()"
>{{ t.label }}</button>
</div>
</div>
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<!-- 출금 계좌 (종류계좌 아코디언) -->
<div class="field">
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
<AccountPicker
v-model="form.walletId"
:wallets="fromWallets"
:disabled="submitting"
title="계좌 선택" placeholder="계좌를 선택하세요"
empty-text="등록된 계좌가 없습니다"
/>
</div>
<!-- 입금 계좌 (이체일 때만) -->
<div v-if="form.type === 'TRANSFER'" class="field">
<span class="field-label">입금 계좌</span>
<AccountPicker
v-model="form.toWalletId"
:wallets="toWallets"
:disabled="submitting"
title="입금 계좌 선택" placeholder="계좌를 선택하세요"
empty-text="등록된 계좌가 없습니다"
/>
</div>
<!-- 분류 트리거 바텀시트(CategoryPicker) (이체 제외) -->
<div v-if="form.type !== 'TRANSFER'" class="field">
<span class="field-label">분류</span>
<button
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
:disabled="submitting" @click="catSheet = true"
>
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
<span class="st-caret"></span>
</button>
</div>
<BottomSheet v-model="catSheet" title="분류 선택">
<CategoryPicker
v-model="form.category"
:type="form.type"
:categories="categories"
:disabled="submitting"
@category-added="loadCategories()"
/>
</BottomSheet>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<!-- 주기 배지 -->
<div class="field">
<span class="field-label">주기</span>
<div class="type-chips">
<button
v-for="f in FREQS" :key="f.value"
type="button" class="chip" :class="{ active: form.frequency === f.value }"
:disabled="submitting"
@click="form.frequency = f.value"
>{{ f.label }}</button>
</div>
</div>
<label v-if="form.frequency === 'YEARLY'">
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
</label>
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">(날짜)
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
</label>
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="goBack" />
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</section>
</template>
<style scoped>
.rform {
max-width: 460px;
margin: 0 auto;
}
.rf-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.rf-title {
font-size: 1.2rem;
font-weight: 700;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
.recur-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.recur-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.recur-form label.row {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.recur-form input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.recur-form label.row input {
padding: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.type-chips,
.wallet-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.chip-expense.active {
border-color: #c0392b;
background: #c0392b;
}
.chip-income.active {
border-color: #2e7d32;
background: #2e7d32;
}
.chip-transfer.active {
border-color: #1565c0;
background: #1565c0;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* 분류 시트 트리거(필드형) */
.sheet-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.sheet-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sheet-trigger.empty .st-label {
opacity: 0.5;
}
.st-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-caret {
opacity: 0.5;
font-size: 0.8rem;
flex-shrink: 0;
}
</style>
+62 -500
View File
@@ -1,103 +1,41 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue'
const dialog = useDialog()
const auth = useAuthStore()
const router = useRouter()
const recurrings = ref([])
const wallets = ref([])
const categories = ref([])
// ( ). . .
const FREE_RECURRING_LIMIT = 2
const atRecurringLimit = computed(
() => !auth.isPremium && recurrings.value.length >= FREE_RECURRING_LIMIT,
)
const loading = ref(false)
const error = ref(null)
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({
title: '',
type: 'EXPENSE',
amount: null,
category: '',
memo: '',
walletKind: '',
walletId: '',
toWalletKind: '',
toWalletId: '',
frequency: 'MONTHLY',
dayOfMonth: 1,
dayOfWeek: 1,
monthOfYear: 1,
startDate: '',
endDate: '',
active: true,
})
const submitting = ref(false)
const formError = ref(null)
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
// (=optgroup / = )
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === form.type)
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
return groups
})
// / (/) ,
const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id)
return w ? w.type : ''
}
function onWalletKindChange() {
form.walletId = ''
}
function onToWalletKindChange() {
form.toWalletId = ''
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function won(n) { return (n ?? 0).toLocaleString('ko-KR') }
function typeLabel(t) {
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
}
function freqLabel(r) {
if (r.frequency === 'DAILY') return '매일'
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}`
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
}
// ( ): <()<() / (·)
function payKey(r) {
if (r.frequency === 'DAILY') return 0
if (r.frequency === 'WEEKLY') return r.dayOfWeek || 0
if (r.frequency === 'YEARLY') return (r.monthOfYear || 0) * 100 + (r.dayOfMonth || 0)
return r.dayOfMonth || 0 // MONTHLY
return r.dayOfMonth || 0
}
// / · ( , )
const PERIODS = [
{ key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] },
{ key: 'MONTH', label: '월간 거래', freqs: ['MONTHLY'] },
{ key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
]
const grouped = computed(() =>
@@ -111,7 +49,6 @@ const grouped = computed(() =>
catMap[cat].minKey = Math.min(catMap[cat].minKey, payKey(r))
if (r.active) catMap[cat].subtotal += r.amount || 0
}
// ,
const cats = Object.values(catMap)
cats.forEach((c) => c.items.sort((a, b) => payKey(a) - payKey(b)))
cats.sort((a, b) => a.minKey - b.minKey || a.category.localeCompare(b.category, 'ko'))
@@ -119,23 +56,12 @@ const grouped = computed(() =>
return { ...p, cats, total, count: items.length }
}).filter((g) => g.count > 0),
)
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function load() {
loading.value = true
error.value = null
try {
const [recs, ws, cats] = await Promise.all([
accountApi.recurrings(),
accountApi.wallets(),
accountApi.categories(),
])
recurrings.value = recs
wallets.value = ws
categories.value = cats
recurrings.value = await accountApi.recurrings()
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
@@ -146,85 +72,30 @@ async function load() {
async function runNow() {
try {
const res = await accountApi.runRecurrings()
alert(`${res.generated}건의 고정 지출가 반영되었습니다.`)
alert(`${res.generated}건의 정기결제가 반영되었습니다.`)
await load()
} catch (e) {
alert(e.response?.data?.message || '반영에 실패했습니다.')
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
})
formError.value = null
formOpen.value = true
async function openCreate() {
if (atRecurringLimit.value) {
const go = await dialog.confirm(
`무료 회원은 정기결제를 ${FREE_RECURRING_LIMIT}개까지 등록할 수 있어요.\n유료로 업그레이드하면 무제한이에요.`,
{ title: '등록 한도', okText: '업그레이드', cancelText: '닫기' },
)
if (go) router.push('/upgrade')
return
}
router.push('/account/recurrings/new')
}
function openEdit(r) {
editId.value = r.id
Object.assign(form, {
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '', frequency: r.frequency,
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.title.trim()) {
formError.value = '이름을 입력하세요.'
return
}
if (!form.amount || form.amount <= 0) {
formError.value = '금액을 입력하세요.'
return
}
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
return
}
if (!form.startDate) {
formError.value = '시작일을 입력하세요.'
return
}
submitting.value = true
const payload = {
title: form.title.trim(),
type: form.type,
amount: Number(form.amount),
category: form.type === 'TRANSFER' ? null : form.category || null,
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
frequency: form.frequency,
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
startDate: form.startDate,
endDate: form.endDate || null,
active: form.active,
}
try {
if (editId.value) await accountApi.updateRecurring(editId.value, payload)
else await accountApi.createRecurring(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
router.push(`/account/recurrings/${r.id}/edit`)
}
async function remove(r) {
if (!(await dialog.confirm(`'${r.title}' 고정 지출를 삭제할까요?\n이미 생성된 내역은 유지됩니다.`, { title: '고정지출 삭제', danger: true }))) return
if (!(await dialog.confirm(`'${r.title}' 정기결제를 삭제할까요?\n이미 생성된 내역은 유지됩니다.`, { title: '정기결제 삭제', danger: true }))) return
try {
await accountApi.removeRecurring(r.id)
await load()
@@ -238,11 +109,15 @@ onMounted(load)
<template>
<section class="recur">
<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" />
<IconBtn icon="plus" title="정기결제 추가" variant="primary" @click="openCreate" />
</div>
<p v-if="atRecurringLimit" class="msg recur-limit">
무료 {{ FREE_RECURRING_LIMIT }}개까지 등록 완료 ·
<RouterLink to="/upgrade" class="recur-upsell">유료 무제한 </RouterLink>
</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
@@ -278,111 +153,7 @@ onMounted(load)
</div>
</section>
</div>
<p v-else-if="!loading" class="msg">등록된 고정 지출 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>고정 지출 {{ editId ? '수정' : '추가' }}</h2>
<form class="recur-form" @submit.prevent="submit">
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting">
<option value="EXPENSE">지출</option>
<option value="INCOME">수입</option>
<option value="TRANSFER">이체</option>
</select>
</label>
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<div class="field">
<div class="field-row">
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
<div class="wallet-radios">
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
<input type="radio" :value="k.value" v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange" />
{{ k.label }}
</label>
</div>
</div>
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
<div v-if="form.type === 'TRANSFER'" class="field">
<div class="field-row">
<span class="field-label">입금 계좌</span>
<div class="wallet-radios">
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
<input type="radio" :value="k.value" v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange" />
{{ k.label }}
</label>
</div>
</div>
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
<label v-if="form.type !== 'TRANSFER'">분류
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select>
</label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label>주기
<select v-model="form.frequency" :disabled="submitting">
<option value="DAILY">매일</option>
<option value="WEEKLY">매주</option>
<option value="MONTHLY">매월</option>
<option value="YEARLY">매년</option>
</select>
</label>
<label v-if="form.frequency === 'WEEKLY'">요일
<select v-model.number="form.dayOfWeek" :disabled="submitting">
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
</select>
</label>
<label v-if="form.frequency === 'YEARLY'">
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
</label>
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">(날짜)
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
</label>
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
<p v-else-if="!loading" class="msg">등록된 정기결제 없습니다.</p>
</section>
</template>
@@ -391,20 +162,6 @@ onMounted(load)
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
h1 {
font-size: 1.5rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
@@ -424,24 +181,12 @@ button {
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.recur-groups {
margin-top: 0.5rem;
}
.period-group {
margin-bottom: 1.5rem;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { border-color: #c0392b; color: #c0392b; }
button.primary { border-color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1); }
.recur-groups { margin-top: 0.5rem; }
.period-group { margin-bottom: 1.5rem; }
.period-head {
display: flex;
align-items: baseline;
@@ -450,18 +195,9 @@ button.primary {
border-bottom: 2px solid var(--color-border);
margin-bottom: 0.5rem;
}
.period-head h2 {
font-size: 1.1rem;
font-weight: 700;
}
.period-total {
font-size: 0.95rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.cat-group {
margin-bottom: 0.6rem;
}
.period-head h2 { font-size: 1.1rem; font-weight: 700; }
.period-total { font-size: 0.95rem; font-weight: 700; color: hsla(160, 100%, 37%, 1); }
.cat-group { margin-bottom: 0.6rem; }
.cat-head {
display: flex;
align-items: baseline;
@@ -470,20 +206,9 @@ button.primary {
background: var(--color-background-soft);
border-radius: 4px;
}
.cat-name {
font-size: 0.88rem;
font-weight: 600;
}
.cat-subtotal {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.8;
}
.recur-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.cat-name { font-size: 0.88rem; font-weight: 600; }
.cat-subtotal { font-size: 0.82rem; font-weight: 600; opacity: 0.8; }
.recur-list { list-style: none; padding-left: 0; margin: 0; }
.recur-row {
display: flex;
align-items: flex-start;
@@ -492,188 +217,25 @@ button.primary {
padding: 0.7rem 0;
border-bottom: 1px solid var(--color-border);
}
.info {
flex: 1;
min-width: 0;
}
.recur-row.inactive {
opacity: 0.55;
}
.line1 {
display: flex;
align-items: center;
gap: 0.4rem;
}
.title {
font-weight: 600;
}
.info { flex: 1; min-width: 0; }
.recur-row.inactive { opacity: 0.55; }
.line1 { display: flex; align-items: center; gap: 0.4rem; }
.title { font-weight: 600; }
.type-badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
.type-badge.income {
color: #2e7d32;
border-color: #2e7d32;
}
.type-badge.expense {
color: #c0392b;
border-color: #c0392b;
}
.off {
font-size: 0.72rem;
opacity: 0.6;
}
.sub {
font-size: 0.83rem;
opacity: 0.75;
margin-top: 0.15rem;
}
.next {
font-size: 0.78rem;
opacity: 0.6;
margin-top: 0.1rem;
}
.actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.actions button {
padding: 0.2rem 0.55rem;
font-size: 0.82rem;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
max-height: 90vh;
overflow-y: auto;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.recur-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.recur-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.recur-form label.row {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.recur-form input,
.recur-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.recur-form label.row input {
padding: 0;
}
/* 계좌/카드 라디오 선택 */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-row {
display: flex;
align-items: center;
gap: 0.9rem;
}
.field-label {
font-size: 0.85rem;
flex-shrink: 0;
}
.wallet-radios {
display: flex;
flex: 1;
min-width: 0;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
padding: 0.1rem 0;
}
.wallet-radios .radio {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
cursor: pointer;
}
.wallet-radios .radio input {
padding: 0;
width: auto;
margin: 0;
}
.r-issuer {
margin-left: 0.2rem;
font-size: 0.72rem;
opacity: 0.6;
}
.empty-hint {
font-size: 0.78rem;
opacity: 0.6;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.type-badge.income { color: #2e7d32; border-color: #2e7d32; }
.type-badge.expense { color: #c0392b; border-color: #c0392b; }
.off { font-size: 0.72rem; opacity: 0.6; }
.sub { font-size: 0.83rem; opacity: 0.75; margin-top: 0.15rem; }
.next { font-size: 0.78rem; opacity: 0.6; margin-top: 0.1rem; }
.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; }
.msg { margin: 0.75rem 0; }
.msg.error { color: #c0392b; }
.msg.recur-limit { color: #b8860b; font-size: 0.85rem; }
.recur-upsell { color: hsla(160, 100%, 37%, 1); font-weight: 700; }
</style>
+379
View File
@@ -0,0 +1,379 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
const loading = ref(false)
const error = ref(null)
const items = ref([]) // { id, kind, name, sub, rows, partial }
const expanded = ref(new Set())
const now = currentYm()
const nowLabel = `${now.y}.${String(now.m).padStart(2, '0')}`
function won(n) {
return n == null ? '-' : Number(n).toLocaleString('ko-KR')
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT'
? '원리금균등'
: v === 'EQUAL_PRINCIPAL'
? '원금균등'
: v === 'BULLET'
? '만기일시'
: '상환'
}
function loanSub(w) {
const parts = [loanMethodLabel(w.loanMethod)]
if (w.loanRate) parts.push(`${w.loanRate}%`)
if (w.loanMonths) parts.push(`${w.loanMonths}개월`)
return parts.join(' · ')
}
async function load() {
loading.value = true
error.value = null
try {
const ws = await accountApi.wallets()
const list = []
// :
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
const { rows, partial } = loanSchedule(w, now)
if (!rows.length) continue
list.push({ id: `loan-${w.id}`, kind: 'LOAN', name: w.name, sub: loanSub(w), rows, partial })
}
// ():
const cards = ws.filter((x) => x.type === 'CARD')
const entryLists = await Promise.all(
cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])),
)
cards.forEach((c, i) => {
const purchases = (entryLists[i] || []).filter(
(e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2,
)
for (const e of purchases) {
const sch = cardInstallmentSchedule(e, now)
if (!sch) continue
const desc = e.category || e.memo || '할부'
list.push({
id: `card-${e.id}`,
kind: 'CARD',
name: c.name,
sub: `${desc} · ${e.installmentMonths}개월 무이자 · ${String(e.entryDate || '').slice(0, 10)}`,
rows: sch.rows,
partial: false,
})
}
})
items.value = list
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
// ( )
const thisMonth = computed(() => {
let p = 0, i = 0, t = 0
for (const it of items.value) {
for (const r of it.rows) {
if (r.label !== nowLabel) continue
p += r.principal || 0
i += r.interest || 0
t += r.total ?? (r.principal || 0) + (r.interest || 0)
}
}
return { p, i, t }
})
//
const remainTotal = computed(() => {
let p = 0, i = 0, t = 0
for (const it of items.value) {
for (const r of it.rows) {
p += r.principal || 0
i += r.interest || 0
t += r.total ?? (r.principal || 0) + (r.interest || 0)
}
}
return { p, i, t }
})
function itemThisMonth(it) {
const r = it.rows.find((x) => x.label === nowLabel)
return r ? (r.total ?? (r.principal || 0) + (r.interest || 0)) : null
}
function itemRemain(it) {
return it.rows.reduce((s, r) => s + (r.total ?? (r.principal || 0) + (r.interest || 0)), 0)
}
function toggle(id) {
const s = new Set(expanded.value)
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = s
}
</script>
<template>
<section class="repay">
<h1 class="title">대출·할부 상환</h1>
<p class="lead">대출 상환금과 카드 할부금을 원금·이자로 나눠, 남은 회차까지 한눈에 봅니다.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-else-if="loading" class="msg">불러오는 중...</p>
<template v-else>
<!-- 합계 요약 -->
<div class="summary">
<div class="sum-card accent">
<span class="k">이번 상환 예정금액</span>
<span class="v">{{ won(thisMonth.t) }}</span>
<span class="split">원금 {{ won(thisMonth.p) }} · 이자 {{ won(thisMonth.i) }}</span>
</div>
<div class="sum-card">
<span class="k">남은 상환 총액</span>
<span class="v">{{ won(remainTotal.t) }}</span>
<span class="split">원금 {{ won(remainTotal.p) }} · 이자 {{ won(remainTotal.i) }}</span>
</div>
</div>
<div v-if="!items.length" class="empty-state">
<p class="empty-emoji">💳</p>
<p class="empty-title">상환 중인 대출·할부가 없어요</p>
<p class="empty-desc">대출 계좌(금리 입력) 카드 할부 결제가 있으면 여기에 표시됩니다.</p>
</div>
<!-- 항목별 -->
<div v-for="it in items" :key="it.id" class="item">
<button type="button" class="item-head" @click="toggle(it.id)">
<span class="kind" :class="it.kind === 'LOAN' ? 'k-loan' : 'k-card'">
{{ it.kind === 'LOAN' ? '대출' : '카드' }}
</span>
<span class="info">
<span class="name">{{ it.name }}</span>
<span class="sub">{{ it.sub }}</span>
</span>
<span class="amt">
<span class="amt-now">{{ won(itemThisMonth(it)) }}</span>
<span class="amt-rem">남은 회차 {{ it.rows.length }} · {{ won(itemRemain(it)) }}</span>
</span>
<span class="chev">{{ expanded.has(it.id) ? '▾' : '▸' }}</span>
</button>
<div v-if="expanded.has(it.id)" class="sched-wrap">
<p v-if="it.partial" class="partial-note">상환 개월(기간) 정보가 없어 이번 이자만 표시합니다. 계좌 관리에서 대출 개월수를 입력하면 전체 스케줄이 계산됩니다.</p>
<table class="sched">
<thead>
<tr>
<th>회차</th>
<th class="num">원금</th>
<th class="num">이자</th>
<th class="num">합계</th>
<th class="num">남은 잔액</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, idx) in it.rows" :key="idx" :class="{ current: r.label === nowLabel }">
<td>{{ r.label }}</td>
<td class="num">{{ won(r.principal) }}</td>
<td class="num">{{ won(r.interest) }}</td>
<td class="num strong">{{ won(r.total) }}</td>
<td class="num muted">{{ won(r.balance) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.repay {
max-width: 820px;
margin: 0 auto;
}
.title {
font-size: 1.3rem;
font-weight: 800;
margin: 0 0 0.25rem;
}
.lead {
font-size: 0.9rem;
opacity: 0.7;
margin: 0 0 1.1rem;
}
.msg {
margin: 0.75rem 0;
opacity: 0.8;
}
.msg.error {
color: #c0392b;
}
.summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
margin-bottom: 1.2rem;
}
.sum-card {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
background: var(--color-background-soft);
}
.sum-card.accent {
border-color: hsla(160, 100%, 37%, 0.6);
background: hsla(160, 100%, 37%, 0.06);
}
.sum-card .k {
font-size: 0.82rem;
opacity: 0.7;
}
.sum-card .v {
font-size: 1.35rem;
font-weight: 800;
}
.sum-card .split {
font-size: 0.78rem;
opacity: 0.65;
}
.item {
border: 1px solid var(--color-border);
border-radius: 12px;
margin-bottom: 0.7rem;
overflow: hidden;
}
.item-head {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 0.85rem;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text);
text-align: left;
}
.kind {
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 700;
padding: 0.15rem 0.45rem;
border-radius: 6px;
}
.k-loan {
background: hsla(215, 90%, 55%, 0.15);
color: hsla(215, 90%, 55%, 1);
}
.k-card {
background: hsla(280, 60%, 55%, 0.15);
color: hsla(280, 60%, 60%, 1);
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.info .name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info .sub {
font-size: 0.78rem;
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amt {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
}
.amt-now {
font-weight: 700;
font-size: 0.92rem;
}
.amt-rem {
font-size: 0.75rem;
opacity: 0.6;
}
.chev {
flex: 0 0 auto;
opacity: 0.5;
font-size: 0.8rem;
}
.sched-wrap {
padding: 0 0.5rem 0.6rem;
overflow-x: auto;
}
.partial-note {
font-size: 0.8rem;
opacity: 0.75;
padding: 0.4rem 0.4rem 0.6rem;
}
.sched {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.sched th,
.sched td {
padding: 0.45rem 0.5rem;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.sched th {
font-size: 0.76rem;
opacity: 0.6;
text-align: left;
font-weight: 600;
}
.sched .num {
text-align: right;
}
.sched td.strong {
font-weight: 700;
}
.sched td.muted {
opacity: 0.6;
}
.sched tr.current td {
background: hsla(160, 100%, 37%, 0.08);
}
.empty-state {
text-align: center;
padding: 2.5rem 1rem;
opacity: 0.85;
}
.empty-emoji {
font-size: 2rem;
}
.empty-title {
font-weight: 700;
margin-top: 0.4rem;
}
.empty-desc {
font-size: 0.85rem;
opacity: 0.7;
margin-top: 0.2rem;
}
@media (max-width: 560px) {
.summary {
grid-template-columns: 1fr;
}
}
</style>
+323
View File
@@ -0,0 +1,323 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const editId = route.params.id ? Number(route.params.id) : null
const isEdit = editId != null
const TYPES = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CASH', label: '현금' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' },
]
const CARD_TYPES = [
{ value: 'CREDIT', label: '신용카드' },
{ value: 'CHECK', label: '체크카드' },
]
const LOAN_METHODS = [
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
{ value: 'BULLET', label: '만기일시' },
{ value: '', label: '선택안함' },
]
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
loanPayment: null,
})
const loading = ref(false)
const submitting = ref(false)
const formError = ref(null)
const typeLabel = (t) => TYPES.find((x) => x.key === t)?.label || ''
function issuerLabel(t) {
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
if (t === 'CARD') return '초기 미결제 금액'
if (t === 'INVEST') return '투자금(투입원금)'
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
return '기록 시작 시 잔액'
}
async function load() {
if (!isEdit) return
loading.value = true
try {
const ws = await accountApi.wallets()
const w = ws.find((x) => x.id === editId)
if (!w) {
formError.value = '계좌를 찾을 수 없습니다.'
return
}
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// ( )
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
loanPayment: w.loanPayment ?? null,
})
} catch (e) {
formError.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
function goBack() {
if (window.history.length > 1) router.back()
else router.replace('/account/wallets')
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' || form.type === 'MINUS' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// ( )
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
loanPayment: isLoan && form.loanPayment ? Number(form.loanPayment) : null,
}
try {
if (isEdit) await accountApi.updateWallet(editId, payload)
else await accountApi.createWallet(payload)
router.replace('/account/wallets')
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="wform">
<header class="wf-head">
<span class="wf-title">{{ isEdit ? `${typeLabel(form.type)} 수정` : '계좌 추가' }}</span>
</header>
<p v-if="loading" class="msg">불러오는 중...</p>
<form v-else class="wallet-form" @submit.prevent="submit">
<!-- 종류 선택(생성 시에만) -->
<div v-if="!isEdit" class="field">
<span class="field-label">계좌 종류</span>
<div class="chip-group">
<button
v-for="t in TYPES" :key="t.key"
type="button" class="chip" :class="{ active: form.type === t.key }"
:disabled="submitting" @click="form.type = t.key"
>{{ t.label }}</button>
</div>
</div>
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="form.type === 'CARD'" class="field">
<span class="field-label">카드 종류</span>
<div class="chip-group">
<button
v-for="c in CARD_TYPES" :key="c.value"
type="button" class="chip" :class="{ active: form.cardType === c.value }"
:disabled="submitting" @click="form.cardType = c.value"
>{{ c.label }}</button>
</div>
</div>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
</label>
<p class="form-hint">
<b>투자금</b>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.001"
placeholder="예: 5.253" :disabled="submitting" />
</label>
<div class="field">
<span class="field-label">상환방식</span>
<div class="chip-group">
<button
v-for="m in LOAN_METHODS" :key="m.value"
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
:disabled="submitting" @click="form.loanMethod = m.value"
>{{ m.label }}</button>
</div>
</div>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
<label v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''"> 상환금(선택)
<input v-model.number="form.loanPayment" type="number" min="0"
placeholder="은행 조회 고정 월납입금 (예: 697868)" :disabled="submitting" />
</label>
<p v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''" class="form-hint">
원리금균등은 <b> 상환금</b> 입력하면 상환표의 원금·이자가 은행 조회액과 정확히 일치합니다. (미입력 잔액·개월수로 추정)
</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="goBack" />
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</section>
</template>
<style scoped>
.wform {
max-width: 460px;
margin: 0 auto;
}
.wf-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.wf-title {
font-size: 1.2rem;
font-weight: 700;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
.wallet-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.wallet-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.wallet-form input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.form-hint {
font-size: 0.76rem;
opacity: 0.65;
margin: -0.2rem 0 0.1rem;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.8rem;
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { boardApi } from '@/api/boardApi'
import { useDialog } from '@/composables/dialog'
const dialog = useDialog()
const items = ref([])
const loading = ref(false)
const error = ref(null)
const busyKey = ref(null) // ( )
const CATEGORY_LABEL = { community: '커뮤니티', saving: '짠테크 수다방', tips: '재테크 팁' }
const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
function keyOf(it) {
return `${it.targetType}:${it.targetId}`
}
function isSpam(it) {
return it.blockReason === '스팸 키워드 감지'
}
function categoryLabel(c) {
return CATEGORY_LABEL[c] || c || '게시판'
}
function linkTo(it) {
return `/board/${it.category || 'community'}/${it.postId}`
}
function formatTime(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
return d.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
async function load() {
loading.value = true
error.value = null
try {
items.value = await boardApi.reports()
} catch (e) {
error.value = e.response?.data?.message || '신고 목록을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function run(it, fn, successMsg) {
busyKey.value = keyOf(it)
try {
await fn()
await load()
if (successMsg) await dialog.alert(successMsg)
} catch (e) {
await dialog.alert(e.response?.data?.message || '처리에 실패했습니다.')
} finally {
busyKey.value = null
}
}
// ( )
async function blockPost(it) {
const reason = await dialog.prompt('블라인드 사유를 입력하세요. (열람 제한 안내에 표시)', {
title: '글 블라인드',
okText: '블라인드',
defaultValue: '신고 누적으로 블라인드 처리되었습니다.',
})
if (reason === null) return
await run(it, () => boardApi.block(it.postId, reason || null), '블라인드 처리했습니다.')
}
// (/)
async function unblock(it) {
const ok = await dialog.confirm('블라인드를 해제하고 신고 기록을 초기화할까요?', {
title: '블라인드 해제',
okText: '해제',
})
if (!ok) return
const call = it.targetType === 'POST'
? () => boardApi.unblock(it.postId)
: () => boardApi.unblockComment(it.targetId)
await run(it, call, '블라인드를 해제했습니다.')
}
//
async function dismiss(it) {
const ok = await dialog.confirm('이 신고를 무시하고 목록에서 제거할까요? (블라인드하지 않고 신고 기록만 삭제)', {
title: '신고 무시',
okText: '무시',
danger: true,
})
if (!ok) return
await run(it, () => boardApi.dismissReport(it.targetType, it.targetId))
}
onMounted(load)
</script>
<template>
<div class="report-admin">
<div class="head">
<p class="desc">
신고 누적 <strong>5</strong> 이상 또는 스팸 키워드 포함 자동 블라인드됩니다.
관리자는 여기서 내용을 검토해 <b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b> 정리할 있습니다.
</p>
<button class="reload" type="button" :disabled="loading" @click="load"> 새로고침</button>
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 </p>
<p v-else-if="!items.length" class="msg empty">🎉 신고된 ·댓글이 없습니다.</p>
<template v-else>
<p class="count">
<strong>{{ items.length }}</strong>
<span v-if="blockedCount"> · 블라인드 {{ blockedCount }}</span>
</p>
<ul class="list">
<li v-for="it in items" :key="keyOf(it)" class="card" :class="{ blocked: it.blocked }">
<div class="row1">
<span class="badge" :class="it.targetType === 'POST' ? 'post' : 'comment'">
{{ it.targetType === 'POST' ? '글' : '댓글' }}
</span>
<span class="cat">{{ categoryLabel(it.category) }}</span>
<span v-if="isSpam(it)" class="badge spam">🤖 스팸감지</span>
<span v-else class="report-count">🚩 {{ it.reportCount }}</span>
<span v-if="it.blocked" class="badge blind">블라인드됨</span>
<span class="time">{{ formatTime(it.lastReportedAt) }}</span>
</div>
<RouterLink :to="linkTo(it)" class="title-link">
<span v-if="it.targetType === 'COMMENT'" class="on-post">[{{ it.title }}] 댓글</span>
<span v-else class="ttl">{{ it.title }}</span>
</RouterLink>
<p class="content">{{ it.content }}</p>
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
<p v-if="it.blockReason" class="block-reason">사유: {{ it.blockReason }}</p>
<div class="actions">
<RouterLink :to="linkTo(it)" class="btn ghost"> 보기</RouterLink>
<button
v-if="it.targetType === 'POST' && !it.blocked"
class="btn danger"
type="button"
:disabled="busyKey === keyOf(it)"
@click="blockPost(it)"
>
블라인드
</button>
<button
v-if="it.blocked"
class="btn"
type="button"
:disabled="busyKey === keyOf(it)"
@click="unblock(it)"
>
블라인드 해제
</button>
<button
v-if="!it.blocked && !isSpam(it)"
class="btn ghost"
type="button"
:disabled="busyKey === keyOf(it)"
@click="dismiss(it)"
>
신고 무시
</button>
</div>
</li>
</ul>
</template>
</div>
</template>
<style scoped>
.report-admin {
padding: 0.5rem 0 2rem;
}
.head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
}
.desc {
flex: 1;
margin: 0;
font-size: 0.9rem;
color: var(--color-text-soft, #666);
line-height: 1.5;
}
.reload {
flex-shrink: 0;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
border-radius: 8px;
padding: 0.4rem 0.7rem;
cursor: pointer;
font-size: 0.85rem;
}
.msg {
padding: 1rem;
color: var(--color-text-soft, #666);
}
.msg.error {
color: #e5484d;
}
.msg.empty {
text-align: center;
padding: 3rem 1rem;
}
.count {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--color-text-soft, #666);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.85rem 1rem;
background: var(--color-background);
}
.card.blocked {
border-color: #e5484d55;
background: #e5484d0d;
}
.row1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
padding: 0.12rem 0.45rem;
border-radius: 6px;
}
.badge.post {
background: #00bc7e22;
color: #00996a;
}
.badge.comment {
background: #3b82f622;
color: #2563eb;
}
.badge.blind {
background: #e5484d22;
color: #e5484d;
}
.badge.spam {
background: #f9731622;
color: #ea580c;
}
.block-reason {
margin: 0 0 0.4rem;
font-size: 0.75rem;
color: #ea580c;
}
.cat {
font-size: 0.78rem;
color: var(--color-text-soft, #888);
}
.report-count {
font-size: 0.8rem;
font-weight: 700;
color: #e5484d;
}
.time {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-soft, #999);
}
.title-link {
display: block;
color: var(--color-text);
text-decoration: none;
font-weight: 600;
margin-bottom: 0.25rem;
}
.title-link:hover {
text-decoration: underline;
}
.on-post {
color: var(--color-text-soft, #777);
font-weight: 500;
}
.content {
margin: 0 0 0.4rem;
font-size: 0.88rem;
color: var(--color-text-soft, #555);
white-space: pre-wrap;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.author {
margin: 0 0 0.6rem;
font-size: 0.78rem;
color: var(--color-text-soft, #999);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.btn {
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
border-radius: 8px;
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.82rem;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
border-color: #e5484d;
color: #e5484d;
}
</style>
+81 -11
View File
@@ -26,24 +26,59 @@ onMounted(async () => {
})
//
const POINT_REASONS = { BOARD_WRITE: '게시판 작성' }
const POINT_REASONS = {
BOARD_WRITE: '게시판 작성',
POST_UPVOTED: '게시글 추천받음',
POST_DOWNVOTED: '게시글 비추천받음',
VOTE_REWARD: '추천 참여',
VOTE_CANCEL: '추천 취소',
ACCOUNT_ENTRY: '가계부 기록',
}
function reasonLabel(r) {
if (r && r.startsWith('MONTH_COMPLETE_')) {
const ym = r.slice('MONTH_COMPLETE_'.length)
return `${ym.slice(0, 4)}${parseInt(ym.slice(4), 10)}월 개근 보너스`
}
return POINT_REASONS[r] || r
}
const _now = new Date()
const histYear = ref(_now.getFullYear())
const histMonth = ref(_now.getMonth() + 1)
const historyOpen = ref(false)
const history = ref([])
const historyLoading = ref(false)
async function openHistory() {
historyOpen.value = true
const isCurrentMonth = computed(() => {
const n = new Date()
return histYear.value === n.getFullYear() && histMonth.value === n.getMonth() + 1
})
async function loadHistory() {
historyLoading.value = true
try {
history.value = await authApi.pointHistory()
history.value = await authApi.pointHistory(histYear.value, histMonth.value)
} catch {
history.value = []
} finally {
historyLoading.value = false
}
}
function prevMonth() {
if (histMonth.value === 1) { histYear.value--; histMonth.value = 12 }
else histMonth.value--
loadHistory()
}
function nextMonth() {
if (isCurrentMonth.value) return
if (histMonth.value === 12) { histYear.value++; histMonth.value = 1 }
else histMonth.value++
loadHistory()
}
async function openHistory() {
historyOpen.value = true
await loadHistory()
}
function fmtDateTime(s) {
if (!s) return ''
const d = new Date(s)
@@ -126,9 +161,8 @@ async function restoreSub() {
}
}
//
function exchangeGiftcon() {
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
function goShop() {
router.push('/settings/shop')
}
// / Android , PC ( )
@@ -288,9 +322,9 @@ async function withdraw() {
<span class="point-more">내역 </span>
</span>
</button>
<button type="button" class="row row-point" @click="exchangeGiftcon">
<span class="k">기프트콘 교환</span>
<span class="v"><span class="soon">업데이트 예정</span></span>
<button type="button" class="row row-point" @click="goShop">
<span class="k">포인트 상점</span>
<span class="v"><span class="point-more"></span></span>
</button>
</section>
@@ -376,8 +410,13 @@ async function withdraw() {
<strong>포인트 적립내역</strong>
<button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button>
</div>
<div class="ph-month-nav">
<button type="button" class="ph-nav-btn" @click="prevMonth"></button>
<span class="ph-month-label">{{ histYear }} {{ histMonth }}</span>
<button type="button" class="ph-nav-btn" :disabled="isCurrentMonth" @click="nextMonth"></button>
</div>
<p v-if="historyLoading" class="ph-msg">불러오는 </p>
<p v-else-if="!history.length" class="ph-msg">적립 내역이 없습니다.</p>
<p v-else-if="!history.length" class="ph-msg"> 적립 내역이 없습니다.</p>
<ul v-else class="ph-list">
<li v-for="(h, i) in history" :key="i">
<div class="ph-main">
@@ -655,6 +694,37 @@ async function withdraw() {
line-height: 1;
cursor: pointer;
}
.ph-month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.ph-month-label {
font-size: 0.9rem;
font-weight: 600;
min-width: 7rem;
text-align: center;
}
.ph-nav-btn {
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.ph-nav-btn:disabled {
opacity: 0.25;
cursor: default;
}
.ph-nav-btn:not(:disabled):hover {
background: var(--color-background-mute);
}
.ph-msg {
padding: 2rem 1rem;
text-align: center;
+326
View File
@@ -7,6 +7,9 @@ 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'
import { accountApi } from '@/api/accountApi'
const dialog = useDialog()
const ui = useUiStore()
@@ -61,6 +64,46 @@ async function changeNotifHour(v) {
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
@@ -127,6 +170,55 @@ async function setDim(dim, value) {
window.desktop?.setWindowSize(w, h)
}
// ===== =====
const resetModalOpen = ref(false)
const resetting = ref(false)
const resetToast = ref(false)
let resetToastTimer = null
function openResetModal() {
if (resetting.value) return
resetModalOpen.value = true
}
async function doResetWithExport() {
resetModalOpen.value = false
resetting.value = true
try {
await exportBackup()
await accountApi.deleteAllData()
showResetToast()
} catch (e) {
window.alert(e?.message || '처리에 실패했습니다.')
} finally {
resetting.value = false
}
}
async function doResetOnly() {
resetModalOpen.value = false
const ok = await dialog.confirm(
'엑셀 백업 없이 모든 데이터를 삭제합니다. 삭제된 데이터는 복구할 수 없습니다. 계속하시겠습니까?',
{ title: '데이터 삭제 확인', okText: '삭제', danger: true },
)
if (!ok) return
resetting.value = true
try {
await accountApi.deleteAllData()
showResetToast()
} catch (e) {
window.alert(e?.response?.data?.message || e?.message || '삭제에 실패했습니다.')
} finally {
resetting.value = false
}
}
function showResetToast() {
resetToast.value = true
clearTimeout(resetToastTimer)
resetToastTimer = setTimeout(() => { resetToast.value = false }, 3000)
}
async function clearAppData() {
if (clearing.value) return
const ok = await dialog.confirm(
@@ -198,6 +290,21 @@ async function clearAppData() {
</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">
@@ -285,6 +392,14 @@ async function clearAppData() {
<span class="row-value">v{{ appVersion }}</span>
</div>
<button type="button" class="row row-btn" :disabled="resetting || clearing" @click="openResetModal">
<div class="row-main">
<span class="row-label danger">가계부 데이터 초기화</span>
<span class="row-sub">내역·계좌·분류·예산·고정지출 모든 데이터 삭제</span>
</div>
<span class="row-value danger">초기화</span>
</button>
<button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
<div class="row-main">
<span class="row-label danger">App Data 삭제</span>
@@ -295,6 +410,58 @@ async function clearAppData() {
</section>
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
<!-- 데이터 초기화 경고 모달 -->
<Teleport to="body">
<div v-if="resetModalOpen" class="reset-backdrop" @click.self="resetModalOpen = false">
<div class="reset-modal" role="dialog" aria-modal="true">
<h2 class="reset-title">가계부 데이터 초기화</h2>
<p class="reset-warn">
내역·계좌·분류·태그·예산·고정지출·투자 내역이 <b>모두 삭제</b>됩니다.<br>
삭제된 데이터는 복구할 없습니다.<br><br>
엑셀 파일로 먼저 백업하시겠습니까?
</p>
<div class="reset-actions">
<button type="button" class="reset-btn export" @click="doResetWithExport">
엑셀로 내보낸 삭제
</button>
<button type="button" class="reset-btn danger" @click="doResetOnly">
백업 없이 바로 삭제
</button>
<button type="button" class="reset-btn cancel" @click="resetModalOpen = false">
취소
</button>
</div>
</div>
</div>
</Teleport>
<!-- 삭제 진행 상태바 -->
<Teleport to="body">
<div v-if="resetting" class="reset-progress-wrap" role="status" aria-label="데이터 삭제 중">
<div class="reset-progress-bar"></div>
<span class="reset-progress-label">데이터를 삭제하는 중입니다</span>
</div>
</Teleport>
<!-- 완료 토스트 -->
<Teleport to="body">
<div v-if="resetToast" class="reset-toast" role="status">데이터 초기화가 완료되었습니다.</div>
</Teleport>
<!-- PIN 설정 모달 -->
<Teleport to="body">
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
<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>
</template>
@@ -303,6 +470,53 @@ async function clearAppData() {
max-width: 560px;
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 {
font-size: 1.4rem;
margin-bottom: 1rem;
@@ -446,4 +660,116 @@ async function clearAppData() {
font-size: 0.78rem;
opacity: 0.5;
}
/* 데이터 초기화 모달 */
.reset-backdrop {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.55);
}
.reset-modal {
width: 100%;
max-width: 360px;
padding: 1.6rem 1.4rem 1.4rem;
border-radius: 14px;
background: var(--color-background);
border: 1px solid var(--color-border);
}
.reset-title {
font-size: 1.05rem;
font-weight: 700;
color: #c0392b;
margin-bottom: 0.9rem;
}
.reset-warn {
font-size: 0.88rem;
line-height: 1.65;
opacity: 0.85;
margin-bottom: 1.3rem;
}
.reset-actions {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.reset-btn {
width: 100%;
padding: 0.7rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
text-align: center;
}
.reset-btn.export {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.reset-btn.danger {
border-color: #c0392b;
color: #c0392b;
}
.reset-btn.cancel {
opacity: 0.6;
}
/* 삭제 진행 상태바 */
.reset-progress-wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 3000;
background: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
padding: 0.6rem 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.reset-progress-bar {
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, hsla(160,100%,37%,1) 0%, hsla(160,100%,37%,0.3) 100%);
background-size: 200% 100%;
animation: progress-slide 1.2s linear infinite;
}
@keyframes progress-slide {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.reset-progress-label {
font-size: 0.82rem;
opacity: 0.75;
}
/* 완료 토스트 */
.reset-toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 3000;
padding: 0.65rem 1.3rem;
border-radius: 999px;
background: #2ecc71;
color: #fff;
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="shop-wrap">
<header class="shop-header">
<h1 class="shop-title">포인트 상점</h1>
</header>
<div v-if="loading" class="shop-loading">불러오는 </div>
<template v-else>
<!-- 포인트 잔액 -->
<section class="points-bar">
<span class="points-label">보유 포인트</span>
<span class="points-value">{{ points.toLocaleString('ko-KR') }} P</span>
</section>
<!-- 유료 회원 안내 -->
<div v-if="isPremium" class="premium-notice">
유료 멤버십 이용 모든 기능 무제한 사용 가능합니다.<br>
포인트 상점 아이템은 무료 회원 전용입니다.
</div>
<!-- 상품 목록 -->
<section v-else class="product-list">
<!-- 추가 계좌 슬롯 -->
<div class="product-card">
<div class="product-icon">🏦</div>
<div class="product-info">
<div class="product-name">추가 계좌 슬롯</div>
<div class="product-desc">
무료 회원은 종류별 계좌 2개까지 등록 가능합니다.<br>
슬롯 1 구매 모든 종류에 +1 추가 (최대 3 구매 가능).
</div>
<div class="product-status">
보유 슬롯: <strong>{{ status.extraWalletSlots }}</strong> / {{ status.maxExtraSlots }}
</div>
</div>
<div class="product-buy">
<div class="product-price">{{ status.walletSlotPrice.toLocaleString('ko-KR') }} P</div>
<button
type="button"
class="buy-btn"
:disabled="status.extraWalletSlots >= status.maxExtraSlots || buying === 'WALLET_SLOT'"
@click="buy('WALLET_SLOT')"
>
<template v-if="status.extraWalletSlots >= status.maxExtraSlots">최대 구매</template>
<template v-else-if="buying === 'WALLET_SLOT'">구매 </template>
<template v-else>구매</template>
</button>
</div>
</div>
<!-- AI 통계 분석 -->
<div class="product-card">
<div class="product-icon"></div>
<div class="product-info">
<div class="product-name">AI 통계 분석 {{ status.aiStatPackSize }}</div>
<div class="product-desc">
통계 화면에서 AI 재무 코멘트를 받을 있습니다.<br>
{{ status.aiStatPackSize }} 이용권 구매 (소진 재구매 가능).
</div>
<div class="product-status">
남은 이용권: <strong>{{ status.aiStatCredits }}</strong>
</div>
</div>
<div class="product-buy">
<div class="product-price">{{ status.aiStatPrice.toLocaleString('ko-KR') }} P</div>
<button
type="button"
class="buy-btn"
:disabled="buying === 'AI_STAT'"
@click="buy('AI_STAT')"
>
<template v-if="buying === 'AI_STAT'">구매 </template>
<template v-else>구매</template>
</button>
</div>
</div>
</section>
<!-- 포인트 적립 안내 -->
<section class="earn-guide">
<h3 class="earn-title">포인트 적립 방법</h3>
<ul class="earn-list">
<li>가계부 내역 작성: <strong>10P</strong> ( 5 한도)</li>
<li>게시판 작성: <strong>10P</strong> ( 5 한도)</li>
<li>게시글 추천받기: <strong>5P</strong></li>
<li>남의 추천하기: <strong>1P</strong></li>
<li> 개근 보너스: <strong>최대 100P</strong></li>
</ul>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import { accountApi } from '@/api/accountApi'
import { authApi } from '@/api/authApi'
const router = useRouter()
const auth = useAuthStore()
const dialog = useDialog()
const loading = ref(true)
const buying = ref('')
const points = ref(0)
const status = ref({
extraWalletSlots: 0,
aiStatCredits: 0,
maxExtraSlots: 3,
walletSlotPrice: 500,
aiStatPrice: 1000,
aiStatPackSize: 10,
})
const isPremium = computed(() => auth.user?.plan === 'PREMIUM')
async function load() {
loading.value = true
try {
const [pt, st] = await Promise.all([authApi.points(), accountApi.shopStatus()])
points.value = pt.points ?? 0
status.value = st
} finally {
loading.value = false
}
}
async function buy(item) {
const labels = { WALLET_SLOT: '추가 계좌 슬롯', AI_STAT: `AI 통계 분석 ${status.value.aiStatPackSize}` }
const prices = { WALLET_SLOT: status.value.walletSlotPrice, AI_STAT: status.value.aiStatPrice }
const ok = await dialog.confirm(
`${labels[item]} (${prices[item].toLocaleString('ko-KR')}P)를 구매하시겠습니까?`
)
if (!ok) return
buying.value = item
try {
status.value = await accountApi.buyShopItem(item)
const pt = await authApi.points()
points.value = pt.points ?? 0
dialog.alert('구매가 완료되었습니다!')
} catch (e) {
const msg = e.response?.data?.message
if (msg === 'SHOP_NOT_ENOUGH_POINTS') {
dialog.alert('포인트가 부족합니다.')
} else if (msg === 'SHOP_WALLET_SLOT_MAX') {
dialog.alert('추가 계좌 슬롯은 최대 3개까지 구매할 수 있습니다.')
} else {
dialog.alert('구매 중 오류가 발생했습니다.')
}
} finally {
buying.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.shop-wrap {
max-width: 480px;
margin: 0 auto;
padding: 0 0 40px;
min-height: 100vh;
background: var(--color-bg, #f7f8fa);
}
.shop-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 16px 12px;
background: var(--color-surface, #fff);
border-bottom: 1px solid var(--color-border, #e8eaed);
}
.back-btn {
font-size: 22px;
line-height: 1;
background: none;
border: none;
cursor: pointer;
color: var(--color-text, #222);
padding: 0 4px;
}
.shop-title {
font-size: 17px;
font-weight: 700;
margin: 0;
}
.shop-loading {
padding: 48px;
text-align: center;
color: var(--color-muted, #888);
}
.points-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: var(--color-surface, #fff);
margin: 12px 16px;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.points-label {
font-size: 14px;
color: var(--color-muted, #888);
}
.points-value {
font-size: 18px;
font-weight: 700;
color: #2563eb;
}
.premium-notice {
margin: 12px 16px;
padding: 16px;
border-radius: 12px;
background: #f0fdf4;
color: #166534;
font-size: 14px;
line-height: 1.6;
border: 1px solid #bbf7d0;
}
.product-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
margin-top: 4px;
}
.product-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: var(--color-surface, #fff);
border-radius: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.product-icon {
font-size: 28px;
flex-shrink: 0;
margin-top: 2px;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
}
.product-desc {
font-size: 13px;
color: var(--color-muted, #888);
line-height: 1.5;
margin-bottom: 6px;
}
.product-status {
font-size: 13px;
color: var(--color-text, #222);
}
.product-buy {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.product-price {
font-size: 15px;
font-weight: 700;
color: #2563eb;
white-space: nowrap;
}
.buy-btn {
padding: 7px 16px;
border-radius: 8px;
border: none;
background: #2563eb;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity .15s;
}
.buy-btn:disabled {
background: #c0c8d8;
cursor: default;
}
.earn-guide {
margin: 20px 16px 0;
padding: 16px;
background: var(--color-surface, #fff);
border-radius: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.earn-title {
font-size: 14px;
font-weight: 700;
margin: 0 0 10px;
}
.earn-list {
margin: 0;
padding: 0 0 0 18px;
list-style: disc;
font-size: 13px;
color: var(--color-muted, #888);
line-height: 1.8;
}
.earn-list strong {
color: var(--color-text, #222);
}
@media (prefers-color-scheme: dark) {
.shop-wrap { background: #111; }
.shop-header { background: #1c1c1e; border-color: #2c2c2e; }
.points-bar, .product-card, .earn-guide { background: #1c1c1e; box-shadow: none; }
.premium-notice { background: #052e16; color: #bbf7d0; border-color: #166534; }
}
</style>