Compare commits

...

93 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
44 changed files with 4232 additions and 1249 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: 'npm' # cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: 'npm' # cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install - name: Install
run: npm ci run: npm ci
+3 -1
View File
@@ -46,10 +46,12 @@ npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예
## 주요 기능 ## 주요 기능
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산) - **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
- **AI 보조** — 자연어 한 줄 입력("어제 스타벅스 5천원"), 영수증 사진 구조화, 분류·계좌 자동 추천, 통계 화면 재무 코멘트·절약 가이드 (Claude Haiku, 집계만 전송·저장은 사용자 확인 후)
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계 - **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름 - **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
- **통계** — 분류별 분석, 순자산 추이, 월말 예상 지출, 지난달 대비 (대시보드 차트) - **통계** — '이번 달 현황'(예산·수입 대비 통합), 분류별 분석, 순자산 추이, 월말 예상 지출(일회성 큰 지출 제외 결정적 계산), 지난달 대비
- **예산** — 월별 카테고리 예산, 예산 대비 진행률 - **예산** — 월별 카테고리 예산, 예산 대비 진행률
- **활동 포인트** — 가계부·게시판 활동 적립, 한 달 개근(모든 날 기록) 랜덤 보너스
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지 - **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
- **테마** — 시스템/라이트/다크 모드 - **테마** — 시스템/라이트/다크 모드
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId "kr.sblog.slimbudget" applicationId "kr.sblog.slimbudget"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3 versionCode 6
versionName "1.0.2" versionName "1.0.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+81 -25
View File
@@ -1,6 +1,6 @@
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황 # 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵. > 최종 업데이트: 2026-07-11. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md) > 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
--- ---
@@ -33,7 +33,7 @@
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png` - 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340) ### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세 - shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 정기결제 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
### 등록 문서 ### 등록 문서
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안 - `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
@@ -52,14 +52,26 @@
--- ---
## 🚧 현재 상태 (2026-06-30) ## 🚧 현재 상태 (2026-07-05)
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`) - ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료 - ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료 - ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
-**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과** -**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상 - ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
- ✅ Google Search Console sblog.kr 도메인 소유권 인증 완료 (DNS TXT)
-**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청 -**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께) - **v4 AAB 빌드 완료** (versionCode 4, 1.0.3) → 비공개 트랙 업로드 대기
- ⏳ 개인사업자 계정 전환 → DUNS 신청 완료(D&B 발급 대기) → 발급 후 Play Console에서 마저 진행
- Play Console 웹사이트 인증(www.sblog.kr) 완료
### 🍎 iOS(App Store) 트랙 — 2026-07-11
-**애플 개발자 등록 완료** (Team ID `G9PDM2C4L6`)
-**애플 로그인(Sign in with Apple) 실기기 동작 확인** — App Store 4.8 요건 충족
-**iOS 번들ID `kr.sblog.slimbudget.app`** (기존 `kr.sblog.slimbudget`는 타 계정 선점 → iOS 전용 새 ID, 안드로이드는 그대로)
- App ID + Sign in with Apple/Push capability, `CODE_SIGN_ENTITLEMENTS` 연결, `ITSAppUsesNonExemptEncryption=false`
-**App Store Connect 앱 생성 + v1.0 (build 1) 심사 제출 완료** — 연령 15+, App Privacy(이메일·사용자콘텐츠·사용자ID / 추적없음 / 앱기능), 무료, 콘텐츠권한 없음, 스크린샷(6.5" 1242×2688 iPhone + iPad 13")
- 로그인은 구글/애플 소셜 전용 → 심사 메모에 **둘러보기(데모)** 안내
-**심사 결과 대기** (승인 시 출시 / 거부 시 Resolution Center 대응)
--- ---
@@ -67,7 +79,7 @@
1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건 1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md) 2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
3. **v3 AAB 업로드**이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨) 3. **v4 AAB 비공개 트랙 업로드**빌드 완료(versionCode 4, 1.0.3), Play Console 업로드 후 검토 제출 (14일 카운트 리셋 안 됨)
4. 승인 → **프로덕션 출시** 4. 승인 → **프로덕션 출시**
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설) ### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
@@ -96,6 +108,7 @@
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기** **v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소) - 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경 - **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
- **외화 환율 갱신 시각** — 환율 자동조회 후 '방금 / N분 전 / N시간 전' 레이블(30초 ticker), FxController `updatedAt` 응답 포함
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관 - **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만) - 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가) - 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
@@ -106,37 +119,80 @@
- `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지 - `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지
- `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker - `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- `CategoryView` 리스트 UI → **칩 그리드 관리 모드** (CategoryPicker admin prop): ✏ 이름수정·✕ 삭제·⠿ 순서변경(토글 후 드래그) - `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. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기 1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. **관리자 신고글 목록 페이지** 2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한 3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1 4. **포인트 상점** (2026-07-10 구현 완료)
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화 - **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입 - **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
- FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
- PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망) ### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백. - 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
- **AI 입력 보조** (편의성, 1순위) - **① 자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움. 내역 화면 '✨ 빠른 입력' 바 진입점. (음성=iOS 키보드 받아쓰기, 실기기 확인)
-**자연어 한 줄 입력**"어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+① -**② 영수증 사진 → AI 구조화** — Claude Vision이 금액·상호·날짜·분류·계좌 추출(`/ocr/receipt-ai`), 실패 시 Google Vision 폴백.
-**영수증 사진 → AI 구조화**멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체) -**③ 분류·계좌 자동 추천** — 사용자 실제 분류·계좌 목록 중에서 선택(할루시네이션 방지 검증).
-**자동 분류 추천** — 가맹점/메모→분류 제안 -**AI 재무 코멘트 + 절약 가이드** — 통계 화면, **집계 수치만** 전송(거래 원문 미전송), 정중체. 예상 지출은 비결정성 때문에 **결정적 계산**(일회성 큰 지출 제외)으로 분리.
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장** -**개인정보처리방침·데이터안전 갱신** — 처리자 Anthropic(Claude) 명시, 전송 데이터 범위·학습 미사용 고지 (terms.js §2·§3, play-store-listing.md 데이터안전).
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송. - 원칙 준수: AI는 폼 prefill·참고 코멘트만, **저장은 사용자 확인 후**.
--- ---
## 🔨 빌드 상태 ## 🔨 빌드 상태
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과 - **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨) - **v3** (versionCode 3): 위 'v2 이후 추가' + **AI 기능(자연어·영수증·분류/계좌·재무코멘트)** 전부 반영 — 빌드 후 비공개 트랙 업로드 (14일 카운트 리셋 안 됨)
- ⚠️ 서버 `.env``SQL_INIT_MODE=always` 추가 필요 (budget_onetime 테이블 자동 생성) - ⚠️ 빌드 전 서버 `.env``ANTHROPIC_API_KEY`(AI) 확인
- ⚠️ 가계부 내역 오류 해결 — 서버 로그 + DevTools 확인 필요 - 가계부 내역 오류 해결됨 (2026-07-05)
- ✅ 1회성 예산(BudgetOnetime) 백엔드 배포 완료 (2026-07-05)
--- ---
## 다음 세션 재개 지점 ## 다음 세션 재개 지점
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`) 0.**[진행중] iOS App Store 심사 결과 대기** — v1.0 제출 완료. 승인 시 출시(자동/수동), 거부 시 Resolution Center 대응(주로 데모 접근 안내)
2. **가계부 내역 오류 디버깅** — 서버 로그(`journalctl -u sb-backend`) + DevTools Network 탭 1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
3. v3 AAB 비공개 트랙 업로드해 테스터가 최신본을 받게 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 멈춤(재부팅 후 대기)로 배포 지연된 적 있음 → 자동재시작/헬스체크
+12 -3
View File
@@ -57,13 +57,13 @@
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요! 오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
문의: sbsb401824@gmail.com 문의: dondwaeji.app@gmail.com
``` ```
### 기타 등록정보 필드 ### 기타 등록정보 필드
- **앱 카테고리**: 금융 (Finance) - **앱 카테고리**: 금융 (Finance)
- **태그**: 가계부, 예산, 자산관리 - **태그**: 가계부, 예산, 자산관리
- **연락처 이메일**: sbsb401824@gmail.com - **연락처 이메일**: dondwaeji.app@gmail.com
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy - **개인정보처리방침 URL**: https://www.sblog.kr/privacy
- **그래픽 에셋**: - **그래픽 에셋**:
- 앱 아이콘: brand/store/icon-512.png - 앱 아이콘: brand/store/icon-512.png
@@ -78,7 +78,7 @@
### 개요 질문 ### 개요 질문
- 데이터를 수집하나요? → **예** - 데이터를 수집하나요? → **예**
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR처리 위탁이며 별도 공유 아님) - 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR·AI(Anthropic)는 대행 처리(처리 위탁)이며 Google Play 기준 '공유'에 해당하지 않음)
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS) - 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion) - 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
@@ -100,6 +100,15 @@
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다. > ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
### AI 편의 기능의 데이터 처리 (처리 위탁, 공유 아님)
> 아래는 개인정보처리방침 3항과 정합. 회원이 해당 기능을 쓸 때에만, 기능 수행 목적으로 Anthropic(Claude) API에 전송됨. Anthropic은 학습에 사용하지 않고, 서비스도 별도 저장하지 않음(폼 자동 채움용). API 키는 서버 보관.
> - 자연어 한 줄 입력: 입력 텍스트 → 금액·분류 등 구조화
> - 영수증 사진 구조화: 영수증 이미지 → 금액·상호·날짜·분류
> - 분류·계좌 자동 추천: 분류·계좌 '이름' 목록
> - 재무 코멘트: 이번 달 **집계 수치만**(개별 거래 원문 미전송)
>
> Play 데이터 안전에서 위 데이터 유형(금융 정보·사진)의 **공유 여부는 계속 '아니요'** (대행 처리는 공유 아님). 다만 방침에 처리자(Anthropic)를 명시했으므로 심사자 교차확인에도 일치함.
--- ---
## 3. 콘텐츠 등급 설문 (IARC) ## 3. 콘텐츠 등급 설문 (IARC)
+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"> <meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.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> <title>돈돼지 가계부</title>
</head> </head>
<body> <body>
+20 -3
View File
@@ -19,6 +19,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 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>"; }; 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; }; 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>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -62,6 +63,7 @@
504EC3061FED79650016851F /* App */ = { 504EC3061FED79650016851F /* App */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
327E84D03001463F00AB0467 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */, 50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */, 504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */, 504EC30B1FED79650016851F /* Main.storyboard */,
@@ -103,8 +105,9 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920; LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920; LastUpgradeCheck = 2660;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@@ -196,6 +199,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -203,8 +207,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_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_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -214,8 +220,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -234,6 +242,7 @@
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -253,6 +262,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -260,8 +270,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_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_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -271,8 +283,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -284,6 +298,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
@@ -295,6 +310,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
@@ -305,7 +321,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget; PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -317,6 +333,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
@@ -326,7 +343,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget; PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key> <key>com.apple.developer.applesignin</key>
<array> <array>
<string>Default</string> <string>Default</string>
+13
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CAPACITOR_DEBUG</key> <key>CAPACITOR_DEBUG</key>
<string>$(CAPACITOR_DEBUG)</string> <string>$(CAPACITOR_DEBUG)</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -10,6 +12,17 @@
<string>영수증·프로필·게시판 사진을 첨부할 때 사진 보관함에서 이미지를 불러옵니다.</string> <string>영수증·프로필·게시판 사진을 첨부할 때 사진 보관함에서 이미지를 불러옵니다.</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>촬영한 사진을 사진 보관함에 저장할 때 사용합니다.</string> <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> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
+10 -3
View File
@@ -230,7 +230,10 @@ watch(() => route.fullPath, () => ui.closeSidebar())
'bottom bottom'; 'bottom bottom';
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
min-height: 100vh; /* 앱 셸: 뷰포트 높이에 고정하고 본문(layout-body)만 스크롤 → 상단 헤더·하단 내비 고정, 페이지 전체 스크롤 방지 */
height: 100vh; /* 폴백 */
height: 100dvh; /* 동적 뷰포트(주소창·안전영역 반영) */
overflow: hidden;
} }
.layout-top { .layout-top {
@@ -394,9 +397,13 @@ watch(() => route.fullPath, () => ui.closeSidebar())
.layout-body { .layout-body {
grid-area: body; grid-area: body;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
/* 하단 고정 내비(56px)+소프트키 인셋만큼 비워 콘텐츠가 가리지 않게 */ /* 하단 고정 내비(56px + 안전영역) 뒤로 마지막 내용이 가려지지 않도록 항상 여백 확보 */
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0) + 1rem); 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 { .layout-bottom {
+38 -4
View File
@@ -15,6 +15,10 @@ const realApi = {
categoryStats({ type, year, month } = {}) { categoryStats({ type, year, month } = {}) {
return http.get('/account/category-stats', { params: { 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 } = {}) { netWorthTrend({ months, weeks, unit } = {}) {
return http.get('/account/networth/trend', { params: { months, weeks, unit } }) return http.get('/account/networth/trend', { params: { months, weeks, unit } })
}, },
@@ -28,8 +32,9 @@ const realApi = {
return http.delete(`/account/entries/${id}`) return http.delete(`/account/entries/${id}`)
}, },
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움 // 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
parseText({ title, text } = {}) { // categoryHints/walletHints: AI가 이 후보들 중에서 분류·결제수단을 고르도록 사용자 목록 전달
return http.post('/account/entries/parse', { title, text }) parseText({ title, text, categoryHints, walletHints } = {}) {
return http.post('/account/entries/parse', { title, text, categoryHints, walletHints })
}, },
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움 // 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') { fxRate(from, to = 'KRW') {
@@ -215,13 +220,42 @@ const realApi = {
return http.post('/account/restore', payload) 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) { ocrReceipt(blob) {
const fd = new FormData() const fd = new FormData()
fd.append('image', blob, 'receipt.jpg') fd.append('image', blob, 'receipt.jpg')
return http.post('/account/ocr/receipt', fd, { return http.post('/account/ocr/receipt', fd, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000, timeout: 30000,
suppressNetError: true,
})
},
// 영수증 AI 구조화 (Claude Vision) — 금액·상호·날짜·분류·계좌를 한 번에. 사용자 후보 목록을 함께 전달
// 비전 처리가 느릴 수 있어 타임아웃 상향, 실패 시 OCR 폴백이 있으므로 전역 배너 억제
parseReceiptAi(blob, { categoryHints = [], walletHints = [] } = {}) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
// 멀티파트 파트 수 제한(Tomcat 기본 10) 회피: 후보를 각각 한 파트에 줄바꿈으로 합쳐 전송
if (categoryHints.length) fd.append('categoryHints', categoryHints.join('\n'))
if (walletHints.length) fd.append('walletHints', walletHints.join('\n'))
return http.post('/account/ocr/receipt-ai', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
suppressNetError: true,
}) })
}, },
} }
@@ -245,7 +279,7 @@ const DEMO_WRITES = new Set([
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings', 'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets', 'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories', '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', 'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade', 'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices', 'refreshPrices', 'refreshAllPrices',
+4 -3
View File
@@ -47,9 +47,10 @@ export const authApi = {
points() { points() {
return http.get('/auth/points') return http.get('/auth/points')
}, },
// 포인트 적립/차감 내역 (최신순) // 포인트 적립/차감 내역 — year+month 지정 시 해당 월, 미지정 시 최근 100건
pointHistory() { pointHistory(year, month) {
return http.get('/auth/point-history') const params = year && month ? { year, month } : {}
return http.get('/auth/point-history', { params })
}, },
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화 // 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
profile() { profile() {
+2 -1
View File
@@ -34,8 +34,9 @@ http.interceptors.response.use(
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') { } else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내 // 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required')) window.dispatchEvent(new CustomEvent('premium:required'))
} else if (!error.response && error.code !== 'ERR_CANCELED') { } else if (!error.response && error.code !== 'ERR_CANCELED' && !error.config?.suppressNetError) {
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외. // 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
// suppressNetError: 자체 폴백/에러표시가 있는 호출(예: 영수증 AI)은 전역 배너를 띄우지 않음.
window.dispatchEvent(new CustomEvent('net:error')) window.dispatchEvent(new CustomEvent('net:error'))
} }
return Promise.reject(error) return Promise.reject(error)
+1 -1
View File
@@ -19,7 +19,7 @@ const TITLES = {
'demo-locked': '둘러보기', 'demo-locked': '둘러보기',
'account-entries': '가계부 내역', 'account-entries': '가계부 내역',
'account-stats': '통계', 'account-stats': '통계',
'account-recurrings': '고정 지출', 'account-recurrings': '정기결제',
'account-wallets': '계좌 관리', 'account-wallets': '계좌 관리',
'account-categories': '분류 관리', 'account-categories': '분류 관리',
'account-budget': '예산 설정', 'account-budget': '예산 설정',
+10 -3
View File
@@ -26,6 +26,7 @@ const icons = {
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>', 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"/>', 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"/>', 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> </script>
@@ -52,13 +53,18 @@ const icons = {
</RouterLink> </RouterLink>
<RouterLink to="/account/recurrings" class="menu-item"> <RouterLink to="/account/recurrings" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
<span>고정 지출</span> <span>정기결제</span>
<span v-if="lockPremium" class="lock-badge">🔒</span> <!-- 무료도 2건까지 사용 가능 🔒 미표시 -->
</RouterLink> </RouterLink>
<RouterLink to="/account/wallets" class="menu-item"> <RouterLink to="/account/wallets" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
<span>계좌 관리</span> <span>계좌 관리</span>
</RouterLink> </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"> <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" /> <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> <span>분류 관리</span>
@@ -120,7 +126,8 @@ const icons = {
.app-sidebar { .app-sidebar {
background: var(--color-background-soft); background: var(--color-background-soft);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
padding: 0 0 1rem; /* iOS 상태바(노치) 영역만큼 위 여백 — 드로어 상단이 상태바에 가리지 않도록 */
padding: env(safe-area-inset-top, 0) 0 1rem;
} }
.drawer-head { .drawer-head {
display: flex; 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>
+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>
+4 -2
View File
@@ -10,6 +10,7 @@ const props = defineProps({
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
admin: { type: Boolean, default: false }, admin: { type: Boolean, default: false },
reorderable: { type: Boolean, default: false }, reorderable: { type: Boolean, default: false },
hideAdd: { type: Boolean, default: false }, // 분류 추가(+대분류/+소분류) 버튼 숨김 (필터 등 선택 전용)
}) })
const emit = defineEmits(['update:modelValue', 'category-added', 'rename', 'delete', 'reorder']) const emit = defineEmits(['update:modelValue', 'category-added', 'rename', 'delete', 'reorder'])
@@ -229,10 +230,10 @@ onBeforeUnmount(destroySortables)
:disabled="disabled" :disabled="disabled"
@click="$emit('update:modelValue', s.name)" @click="$emit('update:modelValue', s.name)"
>{{ s.name }}</button> >{{ s.name }}</button>
<button type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button> <button v-if="!hideAdd" type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button>
</div> </div>
</template> </template>
<button type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button> <button v-if="!hideAdd" type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button>
</div> </div>
<div v-else class="cat-input"> <div v-else class="cat-input">
<input <input
@@ -502,6 +503,7 @@ onBeforeUnmount(destroySortables)
/* ── 인라인 추가 입력 ──────────────────────────────────── */ /* ── 인라인 추가 입력 ──────────────────────────────────── */
.cat-input { .cat-input {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.35rem; gap: 0.35rem;
align-items: center; align-items: center;
} }
+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'], 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'], 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'], 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) 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>
+11 -4
View File
@@ -2,9 +2,9 @@
// 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리) // 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리)
export const SERVICE_NAME = '돈돼지 가계부' 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조 (목적) export const TERMS_OF_SERVICE = `제1조 (목적)
이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다. 이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
@@ -63,15 +63,22 @@ export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자
2. 수집·이용 목적 2. 수집·이용 목적
- 회원 식별 및 로그인 등 계정 관리 - 회원 식별 및 로그인 등 계정 관리
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관 - 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
- AI 기반 편의 기능 제공(자연어 한 줄 입력, 영수증 사진 구조화, 분류·계좌 자동 추천, 이번 달 집계 기반 재무 코멘트)
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리 - 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
- (향후 도입 시) 무료 회원 대상 광고 제공 - (향후 도입 시) 무료 회원 대상 광고 제공
- 서비스 운영, 문의 응대 및 부정 이용 방지 - 서비스 운영, 문의 응대 및 부정 이용 방지
3. 처리위탁 및 제3자 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(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함 - Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다. 영수증 인식·AI 편의 기능은 회원이 제공한 정보를 해당 기능 수행 목적으로만 처리하며, 결과는 폼 자동 채움·참고 코멘트 용도로 저장은 회원 확인 후 이루어집니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
4. 광고 4. 광고
- 현재 서비스에는 광고가 표시되지 않습니다. - 현재 서비스에는 광고가 표시되지 않습니다.
+1 -1
View File
@@ -12,7 +12,7 @@ export function isNativeApple() {
export async function nativeAppleCredential() { export async function nativeAppleCredential() {
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in') const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
const result = await SignInWithApple.authorize({ const result = await SignInWithApple.authorize({
clientId: 'kr.sblog.slimbudget', clientId: 'kr.sblog.slimbudget.app',
redirectURI: 'https://app.sblog.kr/auth/apple/callback', redirectURI: 'https://app.sblog.kr/auth/apple/callback',
scopes: 'email name', scopes: 'email name',
}) })
+44 -1
View File
@@ -139,6 +139,30 @@ const router = createRouter({
component: () => import('../views/account/AccountWalletView.vue'), component: () => import('../views/account/AccountWalletView.vue'),
meta: { requiresAuth: true }, 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', path: '/account/categories',
name: 'account-categories', name: 'account-categories',
@@ -155,7 +179,20 @@ const router = createRouter({
path: '/account/recurrings', path: '/account/recurrings',
name: 'account-recurrings', name: 'account-recurrings',
component: () => import('../views/account/RecurringView.vue'), component: () => import('../views/account/RecurringView.vue'),
meta: { requiresAuth: true, requiresPremium: true }, // 정기결제은 무료도 2건까지 체험(초과 시 업셀) — 유료 게이트 해제
meta: { requiresAuth: true },
},
{
path: '/account/recurrings/new',
name: 'account-recurring-new',
component: () => import('../views/account/RecurringFormView.vue'),
meta: { requiresAuth: true },
},
{
path: '/account/recurrings/:id(\\d+)/edit',
name: 'account-recurring-edit',
component: () => import('../views/account/RecurringFormView.vue'),
meta: { requiresAuth: true },
}, },
// 설정 (앱 하단 내비게이션 → 설정) // 설정 (앱 하단 내비게이션 → 설정)
{ {
@@ -176,6 +213,12 @@ const router = createRouter({
component: () => import('../views/settings/ProfileEditView.vue'), component: () => import('../views/settings/ProfileEditView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/settings/shop',
name: 'settings-shop',
component: () => import('../views/settings/ShopView.vue'),
meta: { requiresAuth: true },
},
], ],
}) })
+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="locked">
<div class="lock-big">🔒</div> <div class="lock-big">🔒</div>
<h2>{{ label }} 로그인 이용할 있어요</h2> <h2>{{ label }} 로그인 이용할 있어요</h2>
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p> <p class="desc">둘러보기에서는 가계부 내역 · 정기결제 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button> <button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div> </div>
</template> </template>
+129 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { ID_LOGIN_ENABLED } from '@/config/features' import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo' import { enterDemo } from '@/demo'
@@ -27,6 +28,8 @@ const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const budgetTotal = ref(0) const budgetTotal = ref(0)
const spentTotal = ref(0) const spentTotal = ref(0)
const entries = ref([]) const entries = ref([])
// 이번 달 상환 예정(대출·카드할부) — 현재일 기준, 가계부에 상환 기록 있으면 제외
const repayDue = ref({ total: 0, count: 0, hadAny: false })
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
@@ -80,6 +83,19 @@ const daysInMonth = computed(() => new Date(year, month, 0).getDate())
const firstWeekday = computed(() => new Date(year, month - 1, 1).getDay()) // 0=일 const firstWeekday = computed(() => new Date(year, month - 1, 1).getDay()) // 0=일
const todayDate = now.getFullYear() === year && now.getMonth() + 1 === month ? now.getDate() : 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 calendarCells = computed(() => {
const cells = [] const cells = []
for (let i = 0; i < firstWeekday.value; i++) cells.push(null) for (let i = 0; i < firstWeekday.value; i++) cells.push(null)
@@ -120,7 +136,7 @@ const shortcuts = [
{ to: '/account/stats', icon: '📊', label: '통계', desc: '차트로 보기' }, { to: '/account/stats', icon: '📊', label: '통계', desc: '차트로 보기' },
{ to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' }, { to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' },
{ to: '/account/budget', 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: '카테고리' }, { to: '/account/categories', icon: '🗂️', label: '분류 관리', desc: '카테고리' },
] ]
@@ -130,17 +146,20 @@ async function load() {
error.value = null error.value = null
try { try {
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지) // 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
const [sum, nw, status, list] = await Promise.all([ const [sum, nw, status, list, ws] = await Promise.all([
accountApi.summary({ year, month }), accountApi.summary({ year, month }),
accountApi.netWorth(), accountApi.netWorth(),
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]), auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
accountApi.list({ year, month }), accountApi.list({ year, month }),
auth.isPremium ? accountApi.wallets() : Promise.resolve([]),
]) ])
summary.value = sum summary.value = sum
networth.value = nw networth.value = nw
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0) budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0) spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
entries.value = list || [] entries.value = list || []
// 상환 예정은 유료 전용 — 무료 회원은 계산·조회 생략
if (auth.isPremium) await computeRepayDue(ws || [], list || [])
// 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외) // 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외)
if (reminders.isNative()) { if (reminders.isNative()) {
const t = new Date() const t = new Date()
@@ -154,6 +173,54 @@ async function load() {
} }
} }
// 이번 달 상환 예정 계산: 대출 월 상환금 + 카드 할부(이번 달 회차).
// '가계부에 상환 기록(REPAYMENT) 있으면 제외' — 해당 계좌는 이번 달 이미 납입한 것으로 봄.
async function computeRepayDue(ws, monthEntries) {
const nw = currentYm()
const nowLabel = `${nw.y}.${String(nw.m).padStart(2, '0')}`
const paid = new Set(
(monthEntries || [])
.filter((e) => e.type === 'REPAYMENT' && e.toWalletId)
.map((e) => e.toWalletId),
)
let total = 0
let count = 0
let hadAny = false
// 대출: 이번 달 상환액(원금+이자)
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
const { rows } = loanSchedule(w, nw)
const row = rows.find((r) => r.label === nowLabel)
if (!row || !row.total) continue
hadAny = true
if (paid.has(w.id)) continue
total += row.total
count += 1
}
// 카드 할부: 이번 달 청구 회차 합(무이자). 카드에 이번 달 상환 기록 있으면 제외.
const cards = ws.filter((x) => x.type === 'CARD')
if (cards.length) {
const lists = await Promise.all(cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])))
cards.forEach((c, i) => {
let cardSum = 0
for (const e of (lists[i] || []).filter((e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2)) {
const sch = cardInstallmentSchedule(e, nw)
if (!sch) continue
const row = sch.rows.find((r) => r.label === nowLabel)
if (row) cardSum += row.total
}
if (cardSum <= 0) return
hadAny = true
if (paid.has(c.id)) return
total += cardSum
count += 1
})
}
repayDue.value = { total, count, hadAny }
}
// 로그인/로그아웃 전환 시 갱신 // 로그인/로그아웃 전환 시 갱신
watch(() => auth.isAuthenticated, (v) => { watch(() => auth.isAuthenticated, (v) => {
if (v) load() if (v) load()
@@ -163,6 +230,7 @@ watch(() => auth.isAuthenticated, (v) => {
budgetTotal.value = 0 budgetTotal.value = 0
spentTotal.value = 0 spentTotal.value = 0
entries.value = [] entries.value = []
repayDue.value = { total: 0, count: 0, hadAny: false }
activeDay.value = 0 activeDay.value = 0
} }
}) })
@@ -175,8 +243,18 @@ onMounted(load)
<!-- ===== 로그인: 요약 대시보드 ===== --> <!-- ===== 로그인: 요약 대시보드 ===== -->
<template v-if="auth.isAuthenticated"> <template v-if="auth.isAuthenticated">
<header class="greet"> <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> <p class="today">{{ monthLabel }} 가계부 요약</p>
</template>
<template v-else>
<h1>{{ displayName }}, 오늘은 아직 기록이 없어요</h1>
<RouterLink to="/account/entries" class="greet-cta">+ 지금 기록하기</RouterLink>
</template>
</header> </header>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
@@ -251,6 +329,22 @@ onMounted(load)
</div> </div>
</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 budget-card">
<div class="card-head"> <div class="card-head">
@@ -385,6 +479,17 @@ onMounted(load)
opacity: 0.65; opacity: 0.65;
font-size: 0.9rem; 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 { .getting-started {
position: relative; position: relative;
@@ -529,6 +634,27 @@ onMounted(load)
} }
/* 예산 대비 지출 카드 */ /* 예산 대비 지출 카드 */
.repay-card {
margin-bottom: 1.2rem;
}
.repay-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.repay-amt {
font-size: 1.5rem;
font-weight: 800;
}
.repay-body.done .repay-amt {
font-size: 1.05rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.repay-sub {
font-size: 0.82rem;
opacity: 0.65;
}
.budget-card { .budget-card {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
+4 -2
View File
@@ -58,7 +58,8 @@ async function buy(product) {
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준) // 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
const freeFeatures = [ const freeFeatures = [
'가계부 내역 기록 · 조회', '가계부 내역 기록 · 조회',
'계좌 관리 (개수 제한 없음)', '계좌 관리 (종류별 2개)',
'정기결제 (2개까지 자동 반영)',
'자주 쓰는 내역', '자주 쓰는 내역',
'월별 수입/지출 요약', '월별 수입/지출 요약',
'기본 분류', '기본 분류',
@@ -67,10 +68,11 @@ const freeFeatures = [
const premiumFeatures = [ const premiumFeatures = [
'전체 통계 (분류별·추이·자산)', '전체 통계 (분류별·추이·자산)',
'예산 설정', '예산 설정',
'고정 지출 자동 반영', '계좌 · 정기결제 무제한',
'영수증 OCR 인식', '영수증 OCR 인식',
'카드 알림 자동 인식', '카드 알림 자동 인식',
'투자 · 자산 관리', '투자 · 자산 관리',
'대출 · 할부 상환표 (원금 · 이자 분리)',
'태그 · 소분류 무제한', '태그 · 소분류 무제한',
'데이터 백업 / 복구 (엑셀)', '데이터 백업 / 복구 (엑셀)',
'광고 제거', '광고 제거',
+247 -18
View File
@@ -1,8 +1,11 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const now = new Date() const now = new Date()
const year = ref(now.getFullYear()) const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1) const month = ref(now.getMonth() + 1)
@@ -24,6 +27,15 @@ const catType = ref('EXPENSE') // 분류별 파이차트 구분
const catData = ref([]) // [{category, total}] const catData = ref([]) // [{category, total}]
const categoryList = ref([]) // 분류 목록(대/소분류 매핑용) const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
const trendData = ref([]) // [{month, netWorth}] 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')}`) const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -62,16 +74,66 @@ const expenseDelta = computed(() => {
return { prev, diff, pct } 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 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 totalDays = daysInMonth(year.value, month.value)
const elapsed = now.getDate() const elapsed = now.getDate()
const projected = elapsed > 0 ? Math.round((monthExpense.value / elapsed) * totalDays) : monthExpense.value if (elapsed < 1) return
return { projected, elapsed, totalDays } // 초반엔 표본이 적어 추정이 크게 튀므로 일정 경과일 전에는 값 대신 안내만 표시
}) 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(() => { const bars = computed(() => {
@@ -264,6 +326,8 @@ async function load() {
monthIncome.value = summary.totalIncome monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense monthExpense.value = summary.totalExpense
prevMonthExpense.value = prevSummary.totalExpense prevMonthExpense.value = prevSummary.totalExpense
loadForecast() // 결정적 예상 지출(비동기)
loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음)
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.' error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally { } 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) { function setUnit(u) {
unit.value = u unit.value = u
loadBar() loadBar()
@@ -335,10 +429,38 @@ onMounted(async () => {
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
<template v-else> <template v-else>
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 --> <!-- 무료 회원 AI 크레딧 소진 안내 -->
<div class="top-grid"> <div v-if="aiNoCredit" class="ai-comment ai-no-credit">
<div class="panel donut-panel"> <div class="ai-comment-head"> AI 코멘트</div>
<h2>예산 대비 지출</h2> <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"> <div v-if="budgetTotal > 0" class="donut-wrap">
<svg viewBox="0 0 120 120" class="donut"> <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" /> <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> <p v-else class="empty">설정된 예산이 없습니다.</p>
</div> </div>
<div class="panel">
<h2>수입 대비 지출</h2>
<div class="ie"> <div class="ie">
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div> <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> <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> {{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
</b> </b>
</div> </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> <span>예상 지출 <small>월말 추정</small></span>
<b class="expense">{{ won(expenseForecast.projected) }}</b> <b class="expense">{{ won(forecast) }}</b>
</div> </div>
<div class="ie-bar"> <div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div> <div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div> </div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></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"> <p v-if="forecastNote" class="forecast-note">{{ forecastNote }}</p>
최근 추세 기준({{ expenseForecast.elapsed }}/{{ expenseForecast.totalDays }} · 현재 {{ won(monthExpense) }})<template v-if="budgetTotal > 0">, 예산 대비 {{ Math.round((expenseForecast.projected / budgetTotal) * 100) }}%</template>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -551,6 +677,89 @@ h2 {
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
margin-bottom: 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 { .panel-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -564,6 +773,26 @@ h2 {
.top-grid .panel { .top-grid .panel {
margin-bottom: 1rem; 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 { .donut-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
+51 -8
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref, nextTick } from 'vue' import { onBeforeUnmount, onMounted, ref, nextTick, watch } from 'vue'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
@@ -11,6 +11,11 @@ const tags = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const newName = ref('') const newName = ref('')
const reorderMode = ref(false)
function toggleReorder() {
reorderMode.value = !reorderMode.value
}
async function load() { async function load() {
loading.value = true loading.value = true
@@ -88,20 +93,32 @@ async function removeTag(tag) {
} }
} }
onMounted(async () => { watch(reorderMode, async (on) => {
await load() if (on) {
await nextTick() await nextTick()
initSortable() initSortable()
} else {
if (sortable) { sortable.destroy(); sortable = null }
}
}) })
onMounted(load)
onBeforeUnmount(() => sortable?.destroy()) onBeforeUnmount(() => sortable?.destroy())
</script> </script>
<template> <template>
<section class="tag-admin"> <section class="tag-admin">
<div class="top-row">
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p> <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"> <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" /> <IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form> </form>
@@ -110,10 +127,10 @@ onBeforeUnmount(() => sortable?.destroy())
<ul v-show="!loading && tags.length" ref="listEl" class="tag-list"> <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"> <li v-for="t in tags" :key="t.id" :data-id="t.id" class="tag-row">
<span class="drag-handle" title="드래그하여 순서 변경"></span> <span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경"></span>
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" /> <input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" :disabled="reorderMode" />
<IconBtn icon="check" title="저장" @click="saveTag(t)" /> <IconBtn v-if="!reorderMode" icon="check" title="저장" @click="saveTag(t)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" /> <IconBtn v-if="!reorderMode" icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
</li> </li>
</ul> </ul>
<p v-if="!loading && !tags.length" class="msg">등록된 태그가 없습니다.</p> <p v-if="!loading && !tags.length" class="msg">등록된 태그가 없습니다.</p>
@@ -133,11 +150,37 @@ onBeforeUnmount(() => sortable?.destroy())
h1 { h1 {
font-size: 1.5rem; font-size: 1.5rem;
} }
.top-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hint { .hint {
font-size: 0.85rem; font-size: 0.85rem;
opacity: 0.7; opacity: 0.7;
margin: 0.5rem 0 1rem; 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 { input {
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+469 -115
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -7,6 +8,9 @@ import { useDialog } from '@/composables/dialog'
import { appLock } from '@/composables/appLock' import { appLock } from '@/composables/appLock'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue' import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import SheetSelect from '@/components/ui/SheetSelect.vue'
import AccountPicker from '@/components/ui/AccountPicker.vue'
import BottomSheet from '@/components/ui/BottomSheet.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr' import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { cardNotif } from '@/native/cardNotif' import { cardNotif } from '@/native/cardNotif'
import { CARD_NOTIF_ENABLED } from '@/config/features' import { CARD_NOTIF_ENABLED } from '@/config/features'
@@ -82,10 +86,30 @@ function resetFilters() {
load() load()
} }
// / // / (?entry) ( )
const route = useRoute()
const router = useRouter()
const formOpen = ref(false) const formOpen = ref(false)
// : + ?entry ( )
function openForm() {
formOpen.value = true
if (route.query.entry == null) router.push({ query: { ...route.query, entry: '1' } })
}
// : + URL ()
function closeForm() {
formOpen.value = false
if (route.query.entry != null) router.back()
}
// ?entry
watch(() => route.query.entry, (v) => { if (v == null) formOpen.value = false })
const editId = ref(null) const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null }) const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
//
const catSheet = ref(false)
watch(() => form.category, (v) => { if (v) catSheet.value = false })
//
const catFilterSheet = ref(false)
watch(() => filters.category, (v) => { if (v) catFilterSheet.value = false })
// ===== ===== // ===== =====
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR'] const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
@@ -99,14 +123,31 @@ watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
if (isForeign.value) form.amount = convertedKrw.value if (isForeign.value) form.amount = convertedKrw.value
}) })
const fxLoading = ref(false) const fxLoading = ref(false)
const rateUpdatedAt = ref(null) // (ms)
const tickNow = ref(Date.now())
let _fxTick = null
onMounted(() => { _fxTick = setInterval(() => { tickNow.value = Date.now() }, 30_000) })
onUnmounted(() => clearInterval(_fxTick))
const rateAgeLabel = computed(() => {
if (!rateUpdatedAt.value || !isForeign.value) return null
const diff = Math.floor((tickNow.value - rateUpdatedAt.value) / 1000)
if (diff < 60) return '방금 업데이트'
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
return `${Math.floor(diff / 86400)}일 전`
})
async function fetchRate() { async function fetchRate() {
if (!isForeign.value || fxLoading.value) return if (!isForeign.value || fxLoading.value) return
fxLoading.value = true fxLoading.value = true
try { try {
const res = await accountApi.fxRate(form.currency, 'KRW') const res = await accountApi.fxRate(form.currency, 'KRW')
// 4 ( step· ) // 4 ( step· )
if (res?.rate != null) form.rate = Math.round(Number(res.rate) * 10000) / 10000 if (res?.rate != null) {
else formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.' form.rate = Math.round(Number(res.rate) * 10000) / 10000
rateUpdatedAt.value = res.updatedAt ? new Date(res.updatedAt).getTime() : Date.now()
} else {
formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
}
} catch { } catch {
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.' formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
} finally { } finally {
@@ -120,6 +161,7 @@ function onCurrencyChange() {
} else { } else {
form.foreignAmount = null form.foreignAmount = null
form.rate = null form.rate = null
rateUpdatedAt.value = null
} }
} }
// / (repayTargetIsCard·repayTargetLoanWallet computed ) // / (repayTargetIsCard·repayTargetLoanWallet computed )
@@ -142,7 +184,7 @@ function monthlyInterestOf(loan) {
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12)) return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
} }
// : · // : ··(PMT) ,
const loanAutoResult = computed(() => { const loanAutoResult = computed(() => {
const loan = repayTargetLoanWallet.value const loan = repayTargetLoanWallet.value
if (!loan) return null if (!loan) return null
@@ -154,6 +196,24 @@ const loanAutoResult = computed(() => {
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths) const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
return { interest, principal, total: principal + interest, needsInput: false } return { interest, principal, total: principal + interest, needsInput: false }
} }
if (loan.loanMethod === 'EQUAL_PAYMENT') {
// ( ) · PMT
let M = null
if (loan.loanPayment) {
M = Number(loan.loanPayment)
} else if (loan.loanAmount && loan.loanMonths && loan.loanRate) {
// PMT : M = P × r(1+r)^n / ((1+r)^n 1)
const r = Number(loan.loanRate) / 100 / 12
const n = loan.loanMonths
M = Math.round(Number(loan.loanAmount) * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1))
}
if (M != null) {
// ( )
const bal = Math.abs(Number(loan.balance) || 0)
const principal = Math.min(Math.max(0, M - interest), bal)
return { interest, principal, total: principal + interest, needsInput: false }
}
}
return { interest: null, principal: null, total: null, needsInput: true } return { interest: null, principal: null, total: null, needsInput: true }
}) })
@@ -250,6 +310,35 @@ async function runOcr(image) {
formError.value = null formError.value = null
try { try {
const blob = await imageToBlob(image) const blob = await imageToBlob(image)
// 1) AI ····
let ai = null
try {
ai = await accountApi.parseReceiptAi(blob, {
categoryHints: allCategoryNames.value,
walletHints: wallets.value.map((w) => w.name),
})
} catch {
ai = null // AI OCR
}
if (ai && ai.recognized) {
form.type = ai.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
if (ai.amount) form.amount = Number(ai.amount)
if (ai.merchant && !form.memo) form.memo = ai.merchant
if (ai.date) form.entryDate = ai.date
if (ai.category) form.category = ai.category
let aiCard = null
if (ai.wallet) {
const w = wallets.value.find((x) => x.name === ai.wallet)
if (w) {
form.walletKind = w.type
form.walletId = w.id
aiCard = w.name
}
}
ocrResult.value = { amount: ai.amount, date: ai.date, store: ai.merchant, card: aiCard, category: ai.category, ai: true }
return
}
// 2) Google Vision OCR +
const { text } = await accountApi.ocrReceipt(blob) const { text } = await accountApi.ocrReceipt(blob)
const r = parseReceiptText(text) const r = parseReceiptText(text)
// ( ) // ( )
@@ -298,6 +387,20 @@ const WALLET_KINDS = [
{ value: 'MINUS', label: '마이너스' }, { value: 'MINUS', label: '마이너스' },
{ value: 'INVEST', label: '증권' }, { value: 'INVEST', label: '증권' },
] ]
// / · ''
// / : ( ), ''() .
// ·· / .
const fromWalletKinds = computed(() =>
WALLET_KINDS.filter((k) => {
if (k.value === 'LOAN') return false
if (k.value === 'CARD') return form.type === 'EXPENSE'
return true
}),
)
// : · ''
const toWalletKinds = computed(() =>
WALLET_KINDS.filter((k) => k.value !== 'CARD' && k.value !== 'LOAN'),
)
const TYPES = [ const TYPES = [
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' }, { value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
{ value: 'INCOME', label: '수입', cls: 'chip-income' }, { value: 'INCOME', label: '수입', cls: 'chip-income' },
@@ -306,6 +409,20 @@ const TYPES = [
] ]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind)) const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind)) const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
// ( select)
function walletOpts(list) {
return list.map((w) => ({ value: w.id, label: w.name || w.issuer }))
}
// / : · ''
const fromWalletOptions = computed(() => {
const opts = walletOpts(walletsOfKind.value)
if (form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE')) {
opts.unshift({ value: '', label: '현금(미지정)' })
}
return opts
})
const toWalletOptions = computed(() => walletOpts(toWalletsOfKind.value))
const repayTargetOptions = computed(() => walletOpts(liabilityWallets.value))
// (////) // (////)
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌') const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
function walletKindOf(id) { function walletKindOf(id) {
@@ -338,11 +455,35 @@ async function loadCategories() {
// ( ) // ( )
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))]) const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
// (//) // (//) +
function onTypeChange() { function onTypeChange() {
form.category = '' form.category = ''
if (!fromWalletKinds.value.some((k) => k.value === form.walletKind)) {
form.walletKind = ''
form.walletId = ''
}
} }
// · ( '' )
const filterTypeOptions = [
{ value: '', label: '구분 전체' },
{ value: 'INCOME', label: '수입' },
{ value: 'EXPENSE', label: '지출' },
{ value: 'TRANSFER', label: '이체' },
]
const filterCategoryOptions = computed(() => [
{ value: '', label: '분류 전체' },
...allCategoryNames.value.map((c) => ({ value: c, label: c })),
])
const filterWalletOptions = computed(() => [
{ value: '', label: '계좌 전체' },
...wallets.value.map((w) => ({ value: w.id, label: w.name })),
])
const filterTagOptions = computed(() => [
{ value: '', label: '태그 전체' },
...tagOptions.value.map((t) => ({ value: t.id, label: t.name })),
])
// ( ) // ( )
const tagOptions = ref([]) // [{ id, name }] const tagOptions = ref([]) // [{ id, name }]
const selectedTagIds = ref([]) const selectedTagIds = ref([])
@@ -471,12 +612,19 @@ function openCreate() {
editId.value = null editId.value = null
editingPending.value = false editingPending.value = false
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null }) Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
rateUpdatedAt.value = null
selectedTagIds.value = [] selectedTagIds.value = []
formError.value = null formError.value = null
formInfo.value = '' formInfo.value = ''
formOpen.value = true openForm()
tryClipboardOnOpen() // / ( ) tryClipboardOnOpen() // / ( )
} }
// : , .
// · , ·.
function openQuickInput() {
openCreate()
openPasteModal()
}
function openEdit(e) { function openEdit(e) {
editId.value = e.id editId.value = e.id
editingPending.value = !!e.pending editingPending.value = !!e.pending
@@ -505,12 +653,13 @@ function openEdit(e) {
const nameToId = {} const nameToId = {}
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id)) tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean) selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
rateUpdatedAt.value = null
pasteDetected.value = null pasteDetected.value = null
pasteModalOpen.value = false pasteModalOpen.value = false
pasteText.value = '' pasteText.value = ''
formError.value = null formError.value = null
formInfo.value = '' formInfo.value = ''
formOpen.value = true openForm()
} }
async function submit() { async function submit() {
@@ -552,7 +701,7 @@ async function submit() {
if (editId.value) { if (editId.value) {
await accountApi.remove(editId.value) await accountApi.remove(editId.value)
} }
formOpen.value = false closeForm()
await load() await load()
} catch (e) { } catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.' formError.value = e.response?.data?.message || '저장에 실패했습니다.'
@@ -602,7 +751,7 @@ async function submit() {
} else { } else {
await accountApi.create(payload) await accountApi.create(payload)
} }
formOpen.value = false closeForm()
// //
if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder() if (reminders.isNative() && payload.entryDate === todayStr()) reminders.clearTodayReminder()
await load() await load()
@@ -624,7 +773,7 @@ async function remove(e) {
} }
} }
// ( confirm PC 'sb_pt' ) // ( confirm PC 'sb_pt' )
const recurConfirm = reactive({ open: false, title: '', dom: 1, amount: 0, payload: null }) const recurConfirm = reactive({ open: false, title: '', dom: 1, amount: 0, payload: null })
const flashMsg = ref('') const flashMsg = ref('')
let flashTimer = null let flashTimer = null
@@ -634,9 +783,9 @@ function flash(msg) {
flashTimer = setTimeout(() => (flashMsg.value = ''), 2800) flashTimer = setTimeout(() => (flashMsg.value = ''), 2800)
} }
// '' // ''
function registerAsRecurring() { function registerAsRecurring() {
if (form.type === 'REPAYMENT') return // // if (form.type === 'REPAYMENT') return // //
const amount = Number(form.amount) const amount = Number(form.amount)
if (!amount || amount <= 0) { if (!amount || amount <= 0) {
formError.value = '금액을 올바르게 입력하세요.' formError.value = '금액을 올바르게 입력하세요.'
@@ -645,7 +794,7 @@ function registerAsRecurring() {
const title = const title =
(form.memo || '').trim() || (form.memo || '').trim() ||
(form.category || '').trim() || (form.category || '').trim() ||
(form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '고정 지출') (form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '정기결제')
const base = form.entryDate || todayStr() const base = form.entryDate || todayStr()
const [y, m, d] = base.split('-').map(Number) const [y, m, d] = base.split('-').map(Number)
const dom = d const dom = d
@@ -677,10 +826,10 @@ async function doRegisterRecurring() {
try { try {
await accountApi.createRecurring(recurConfirm.payload) await accountApi.createRecurring(recurConfirm.payload)
recurConfirm.open = false recurConfirm.open = false
formOpen.value = false closeForm()
flash('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.') flash('정기결제로 등록했습니다. 정기결제 화면에서 주기를 변경할 수 있어요.')
} catch (e) { } catch (e) {
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.' formError.value = e.response?.data?.message || '정기결제 등록에 실패했습니다.'
recurConfirm.open = false recurConfirm.open = false
} finally { } finally {
submitting.value = false submitting.value = false
@@ -770,57 +919,79 @@ const pasteText = ref('')
const pasteError = ref('') const pasteError = ref('')
function applyParsed(p) { function applyParsed(p) {
if (!p || !p.recognized) return if (!p || !p.recognized) return
form.type = p.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
if (p.amount) form.amount = Number(p.amount) if (p.amount) form.amount = Number(p.amount)
if (p.merchant) form.memo = p.merchant if (p.merchant) form.memo = p.merchant
if (p.date) form.entryDate = p.date if (p.date) form.entryDate = p.date
categoryMajor.value = '' // : (from) / ·(to)
const fromW = p.wallet ? wallets.value.find((x) => x.name === p.wallet) : null
const toW = p.toWallet ? wallets.value.find((x) => x.name === p.toWallet) : null
// / '(REPAYMENT)' / (=·=)
const isTransfer = p.type === 'TRANSFER'
const repayment = isTransfer && toW && (toW.type === 'LOAN' || toW.type === 'CARD')
form.type =
p.type === 'INCOME' ? 'INCOME' : repayment ? 'REPAYMENT' : isTransfer ? 'TRANSFER' : 'EXPENSE'
// · . AI ( )
form.category = isTransfer || repayment ? '' : p.category || ''
if (fromW) {
form.walletKind = fromW.type
form.walletId = fromW.id
}
if ((isTransfer || repayment) && toW) {
form.toWalletKind = toW.type
form.toWalletId = toW.id
}
if (repayment) {
const amt = p.amount ? Number(p.amount) : null
// toWalletId principal/interest/ '' .
nextTick(() => {
const loan = repayTargetLoanWallet.value
if (loan && loanAutoResult.value && loanAutoResult.value.needsInput && amt) {
// () / (calcLoanRepayment)
loanPaymentAmount.value = amt
} else if (!loan && amt) {
// · ( )
form.principal = amt
form.interest = null
}
// BULLET()·EQUAL_PRINCIPAL() loanAutoResult /
})
}
pasteDetected.value = null pasteDetected.value = null
pasteModalOpen.value = false pasteModalOpen.value = false
pasteText.value = '' pasteText.value = ''
pasteError.value = '' pasteError.value = ''
openForm() //
} }
async function parseAndApply(text, { silent } = {}) { async function parseAndApply(text, { silent } = {}) {
const t = (text || '').trim() const t = (text || '').trim()
if (!t) return false if (!t) return false
try { try {
const p = await accountApi.parseText({ text: t }) // · AI
const p = await accountApi.parseText({
text: t,
categoryHints: allCategoryNames.value,
walletHints: wallets.value.map((w) => w.name),
})
if (p && p.recognized) { if (p && p.recognized) {
if (silent) pasteDetected.value = p if (silent) pasteDetected.value = p
else applyParsed(p) else applyParsed(p)
return true return true
} }
} catch { } catch (e) {
// // ,
console.error('[parseAndApply] 실패:', e)
} }
return false return false
} }
// ( ) // : (iOS clipboard.readText() '' ).
async function tryClipboardOnOpen() { // . .
function tryClipboardOnOpen() {
pasteDetected.value = null pasteDetected.value = null
pasteText.value = ''
try {
const txt = await navigator.clipboard.readText()
await parseAndApply(txt, { silent: true })
} catch {
//
}
} }
// '·' + // ' ' ( ). .
async function openPasteModal() { function openPasteModal() {
pasteError.value = '' pasteError.value = ''
pasteModalOpen.value = true pasteModalOpen.value = true
if (!pasteText.value) {
try {
const txt = await navigator.clipboard.readText()
if (txt) {
pasteText.value = txt
await parseAndApply(txt, { silent: true })
}
} catch {
//
}
}
} }
// · // ·
async function analyzePasted() { async function analyzePasted() {
@@ -831,14 +1002,12 @@ async function analyzePasted() {
onMounted(async () => { onMounted(async () => {
// ( ) // ( )
// (403/ ) // 2
if (isPremium.value) {
try { try {
await accountApi.runRecurrings() await accountApi.runRecurrings()
} catch { } catch {
// //
} }
}
load() load()
loadTagOptions() loadTagOptions()
loadWallets() loadWallets()
@@ -851,6 +1020,8 @@ onMounted(async () => {
<template> <template>
<section class="account"> <section class="account">
<!-- 목록 ( 열리면 숨김 폼이 페이지처럼 콘텐츠 영역을 차지) -->
<template v-if="!formOpen">
<header v-if="pendingCount" class="account-head"> <header v-if="pendingCount" class="account-head">
<span class="pending-count">확인 필요 {{ pendingCount }}</span> <span class="pending-count">확인 필요 {{ pendingCount }}</span>
</header> </header>
@@ -895,24 +1066,24 @@ onMounted(async () => {
v-model="filters.keyword" type="text" class="f-keyword" v-model="filters.keyword" type="text" class="f-keyword"
placeholder="메모·분류 검색" @keyup.enter="applyFilters" placeholder="메모·분류 검색" @keyup.enter="applyFilters"
/> />
<select v-model="filters.type"> <div class="f-sel"><SheetSelect v-model="filters.type" :options="filterTypeOptions" title="구분" placeholder="구분 전체" /></div>
<option value="">구분 전체</option> <div class="f-sel">
<option value="INCOME">수입</option> <button type="button" class="sheet-trigger" :class="{ empty: !filters.category }" @click="catFilterSheet = true">
<option value="EXPENSE">지출</option> <span class="st-label">{{ filters.category || '분류 전체' }}</span>
<option value="TRANSFER">이체</option> <span class="st-caret"></span>
</select> </button>
<select v-model="filters.category"> </div>
<option value="">분류 전체</option> <BottomSheet v-model="catFilterSheet" title="분류 선택">
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option> <button type="button" class="cat-all-btn" :class="{ active: !filters.category }" @click="filters.category = ''; catFilterSheet = false">분류 전체</button>
</select> <CategoryPicker
<select v-model="filters.walletId"> v-model="filters.category"
<option value="">계좌 전체</option> :type="filters.type || 'EXPENSE'"
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option> :categories="categories"
</select> hide-add
<select v-model="filters.tagId"> />
<option value="">태그 전체</option> </BottomSheet>
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option> <div class="f-sel"><AccountPicker v-model="filters.walletId" :wallets="wallets" all-label="계좌 전체" title="계좌 선택" /></div>
</select> <div class="f-sel"><SheetSelect v-model="filters.tagId" :options="filterTagOptions" title="태그" placeholder="태그 전체" /></div>
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" /> <IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
<IconBtn icon="refresh" title="초기화" @click="resetFilters" /> <IconBtn icon="refresh" title="초기화" @click="resetFilters" />
</div> </div>
@@ -932,6 +1103,16 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- 자연어 빠른 입력 진입점(AI): 입력 자동 채움 -->
<button type="button" class="quick-input-bar" @click="openQuickInput">
<span class="qib-icon" aria-hidden="true"></span>
<span class="qib-body">
<span class="qib-title">빠른 입력</span>
<span class="qib-sub">어제 삼성카드로 라면 5천원 처럼 줄로</span>
</span>
<span class="qib-plus" aria-hidden="true"></span>
</button>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
@@ -986,15 +1167,12 @@ onMounted(async () => {
<p class="empty-desc"> 수입·지출을 기록해보세요.</p> <p class="empty-desc"> 수입·지출을 기록해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 내역 추가</button> <button type="button" class="empty-cta" @click="openCreate"> 내역 추가</button>
</div> </div>
</template>
<!-- 추가/수정 모달 --> <!-- 내역 추가/수정: 목록 대신 콘텐츠 영역에 인라인 페이지로(헤더·하단바 유지, 뒤로가기로 닫힘) -->
<Teleport to="body"> <div v-else class="entry-page">
<Transition name="slide-up"> <header class="ep-head">
<div v-if="formOpen" class="modal-backdrop entry-backdrop"> <span class="ep-title">{{ editId ? '내역 수정' : '내역 추가' }}</span>
<div class="modal entry-modal" role="dialog" aria-modal="true"> </header>
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
<form class="entry-form" @submit.prevent="submit"> <form class="entry-form" @submit.prevent="submit">
<!-- 영수증 스캔 · 문자/푸시 (같은 상단) --> <!-- 영수증 스캔 · 문자/푸시 (같은 상단) -->
<div v-if="!isRepayment" class="receipt-box"> <div v-if="!isRepayment" class="receipt-box">
@@ -1012,17 +1190,19 @@ onMounted(async () => {
<button <button
type="button" class="paste-btn" :class="{ detected: pasteDetected }" type="button" class="paste-btn" :class="{ detected: pasteDetected }"
:disabled="submitting" @click="openPasteModal" :disabled="submitting" @click="openPasteModal"
>📋 문자·푸시<span v-if="pasteDetected" class="pb-dot"></span></button> > 빠른 입력<span v-if="pasteDetected" class="pb-dot"></span></button>
</div> </div>
<span v-if="ocrRunning" class="receipt-status">영수증 인식 </span> <span v-if="ocrRunning" class="receipt-status">영수증 인식 </span>
<span v-else-if="ocrResult" class="receipt-status ok"> <span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.ai"> </template>
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template> <template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template> <template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template> <template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
<template v-if="ocrResult.category"> · {{ ocrResult.category }}</template>
<template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template> <template v-if="ocrResult.card"> · 💳{{ ocrResult.card }}</template>
자동 입력됨 자동 입력됨
</span> </span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span> <span v-else class="receipt-hint">사진에서 금액·날짜·상호·분류 자동 입력</span>
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div> <div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div> </div>
@@ -1042,18 +1222,20 @@ onMounted(async () => {
<div class="field"> <div class="field">
<span class="field-label">계좌 종류</span> <span class="field-label">계좌 종류</span>
<div class="wallet-chips"> <div class="wallet-chips">
<button v-for="k in WALLET_KINDS" :key="k.value" type="button" <button v-for="k in fromWalletKinds" :key="k.value" type="button"
class="chip" :class="{ active: form.walletKind === k.value }" class="chip" :class="{ active: form.walletKind === k.value }"
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()"> :disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
{{ k.label }} {{ k.label }}
</button> </button>
</div> </div>
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting"> <SheetSelect
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option> v-if="form.walletKind"
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id"> v-model="form.walletId"
{{ w.name || w.issuer }} :options="fromWalletOptions"
</option> :disabled="submitting"
</select> title="계좌 선택" placeholder="계좌를 선택하세요"
:empty-text="`등록된 ${walletKindLabel}이(가) 없습니다`"
/>
</div> </div>
<!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) --> <!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) -->
<label v-if="showInstallment">할부 <label v-if="showInstallment">할부
@@ -1067,31 +1249,35 @@ onMounted(async () => {
<div class="field"> <div class="field">
<span class="field-label">입금 종류</span> <span class="field-label">입금 종류</span>
<div class="wallet-chips"> <div class="wallet-chips">
<button v-for="k in WALLET_KINDS" :key="k.value" type="button" <button v-for="k in toWalletKinds" :key="k.value" type="button"
class="chip" :class="{ active: form.toWalletKind === k.value }" class="chip" :class="{ active: form.toWalletKind === k.value }"
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()"> :disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
{{ k.label }} {{ k.label }}
</button> </button>
</div> </div>
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting"> <SheetSelect
<option value="">입금 계좌 선택</option> v-if="form.toWalletKind"
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id"> v-model="form.toWalletId"
{{ w.name || w.issuer }} :options="toWalletOptions"
</option> :disabled="submitting"
</select> title="입금 계좌 선택" placeholder="계좌를 선택하세요"
empty-text="등록된 계좌가 없습니다"
/>
</div> </div>
</template> </template>
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 (+카드면 연회비). 원금=이체·이자/연회비=지출 --> <!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 (+카드면 연회비). 원금=이체·이자/연회비=지출 -->
<template v-if="isRepayment"> <template v-if="isRepayment">
<label>대상(대출/카드) <div class="field">
<select v-model="form.toWalletId" :disabled="submitting"> <span class="field-label">대상(대출/카드)</span>
<option value="">(선택)</option> <AccountPicker
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id"> v-model="form.toWalletId"
{{ w.name || w.issuer }} :wallets="liabilityWallets"
</option> :disabled="submitting"
</select> title="상환 대상 선택" placeholder="대출/카드를 선택하세요"
</label> empty-text="등록된 대출/카드가 없습니다"
/>
</div>
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 --> <!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<template v-if="repayTargetLoanWallet"> <template v-if="repayTargetLoanWallet">
<!-- 원리금균등: 납입금액 직접 입력 --> <!-- 원리금균등: 납입금액 직접 입력 -->
@@ -1123,7 +1309,17 @@ onMounted(async () => {
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label> <label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
</template> </template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류 <div v-if="form.type === 'INCOME' || form.type === 'EXPENSE'" class="field">
<span class="field-label">분류</span>
<button
type="button" class="sheet-trigger" :class="{ empty: !form.category }"
:disabled="submitting" @click="catSheet = true"
>
<span class="st-label">{{ form.category || '분류를 선택하세요' }}</span>
<span class="st-caret"></span>
</button>
</div>
<BottomSheet v-model="catSheet" title="분류 선택">
<CategoryPicker <CategoryPicker
v-model="form.category" v-model="form.category"
:type="form.type" :type="form.type"
@@ -1131,7 +1327,7 @@ onMounted(async () => {
:disabled="submitting" :disabled="submitting"
@category-added="loadCategories" @category-added="loadCategories"
/> />
</label> </BottomSheet>
<label v-if="!isRepayment" class="amount-label"> <label v-if="!isRepayment" class="amount-label">
금액 금액
<div class="amount-row"> <div class="amount-row">
@@ -1149,6 +1345,7 @@ onMounted(async () => {
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" /> <input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button> <button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button>
</div> </div>
<span v-if="rateAgeLabel" class="fx-age">{{ rateAgeLabel }}</span>
</div> </div>
<span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span> <span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
</div> </div>
@@ -1178,7 +1375,7 @@ onMounted(async () => {
class="to-recurring" class="to-recurring"
:disabled="submitting" :disabled="submitting"
@click="registerAsRecurring" @click="registerAsRecurring"
>🔁 내역을 고정지출 등록</button> >🔁 내역을 정기결제 등록</button>
<button <button
v-if="editId && (form.type === 'INCOME' || form.type === 'EXPENSE')" v-if="editId && (form.type === 'INCOME' || form.type === 'EXPENSE')"
@@ -1189,14 +1386,11 @@ onMounted(async () => {
> 자주 쓰는 내역으로 저장</button> > 자주 쓰는 내역으로 저장</button>
<div class="buttons"> <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" /> <IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div> </div>
</form> </form>
</div> </div>
</div>
</Transition>
</Teleport>
<!-- 영수증 등록 방식 선택 (카메라/갤러리) --> <!-- 영수증 등록 방식 선택 (카메라/갤러리) -->
<Teleport to="body"> <Teleport to="body">
@@ -1222,17 +1416,17 @@ onMounted(async () => {
</Transition> </Transition>
</Teleport> </Teleport>
<!-- 고정지출 등록 확인 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) --> <!-- 정기결제 등록 확인 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
<div v-if="recurConfirm.open" class="modal-backdrop" @click.self="recurConfirm.open = false"> <div v-if="recurConfirm.open" class="modal-backdrop" @click.self="recurConfirm.open = false">
<div class="modal confirm-modal" role="dialog" aria-modal="true"> <div class="modal confirm-modal" role="dialog" aria-modal="true">
<h2>고정지출 등록</h2> <h2>정기결제 등록</h2>
<p class="confirm-body"> <p class="confirm-body">
<b>{{ recurConfirm.title }}</b><br /> <b>{{ recurConfirm.title }}</b><br />
매월 <b>{{ recurConfirm.dom }}</b> · <b>{{ won(recurConfirm.amount) }}</b> 으로 등록할까요? 매월 <b>{{ recurConfirm.dom }}</b> · <b>{{ won(recurConfirm.amount) }}</b> 으로 등록할까요?
</p> </p>
<p class="confirm-sub">주기·시작일은 고정지출 화면에서 변경할 있어요.</p> <p class="confirm-sub">주기·시작일은 정기결제 화면에서 변경할 있어요.</p>
<div class="buttons"> <div class="buttons">
<IconBtn icon="close" title="취소" @click="recurConfirm.open = false" /> <IconBtn icon="close" title="취소" @click="recurConfirm.open = false" />
<IconBtn icon="save" title="등록" variant="primary" :disabled="submitting" @click="doRegisterRecurring" /> <IconBtn icon="save" title="등록" variant="primary" :disabled="submitting" @click="doRegisterRecurring" />
@@ -1249,16 +1443,16 @@ onMounted(async () => {
<div v-if="pasteModalOpen" class="modal-backdrop quick-picker-backdrop" @click.self="pasteModalOpen = false"> <div v-if="pasteModalOpen" class="modal-backdrop quick-picker-backdrop" @click.self="pasteModalOpen = false">
<div class="modal paste-modal" role="dialog" aria-modal="true"> <div class="modal paste-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="pasteModalOpen = false">×</button> <button class="close" type="button" @click="pasteModalOpen = false">×</button>
<h2>문자·푸시 붙여넣기</h2> <h2>빠른 입력</h2>
<p class="pm-hint">문자/푸시 내용을 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 입력됩니다.</p> <p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p>
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="여기에 붙여넣기 (예: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27)"></textarea> <textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원&#10;또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27"></textarea>
<div v-if="pasteDetected" class="paste-detected"> <div v-if="pasteDetected && pasteDetected.amount" class="paste-detected">
감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template> 감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
</div> </div>
<p v-if="pasteError" class="msg error">{{ pasteError }}</p> <p v-if="pasteError" class="msg error">{{ pasteError }}</p>
<div class="buttons"> <div class="buttons pm-buttons">
<IconBtn icon="close" title="취소" @click="pasteModalOpen = false" /> <button type="button" class="pm-btn pm-cancel" @click="pasteModalOpen = false">취소</button>
<IconBtn icon="save" title="분석·채우기" variant="primary" @click="analyzePasted" /> <button type="button" class="pm-btn pm-primary" @click="analyzePasted"> 분석해서 채우기</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1360,11 +1554,113 @@ button.primary {
flex: 1; flex: 1;
min-width: 8rem; min-width: 8rem;
} }
.filter-bar .f-sel {
flex: 1 1 7rem;
min-width: 6.5rem;
}
/* 시트 트리거(필드형) — 분류 선택 등 */
.sheet-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
text-align: left;
}
.sheet-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sheet-trigger.empty .st-label {
opacity: 0.5;
}
.st-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-caret {
opacity: 0.5;
font-size: 0.8rem;
flex-shrink: 0;
}
.cat-all-btn {
width: 100%;
padding: 0.55rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
}
.cat-all-btn.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.summary { .summary {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
/* 자연어 빠른 입력 바 */
.quick-input-bar {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
margin-bottom: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 0.45);
border-radius: 12px;
background: hsla(160, 100%, 37%, 0.08);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.quick-input-bar:hover {
background: hsla(160, 100%, 37%, 0.14);
}
.quick-input-bar .qib-icon {
font-size: 1.25rem;
line-height: 1;
}
.quick-input-bar .qib-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.quick-input-bar .qib-title {
font-weight: 700;
font-size: 0.95rem;
color: hsla(160, 100%, 32%, 1);
}
.quick-input-bar .qib-sub {
font-size: 0.78rem;
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-input-bar .qib-plus {
font-size: 1.3rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
flex: 0 0 auto;
}
.card { .card {
flex: 1; flex: 1;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -1654,6 +1950,25 @@ button.primary {
opacity: 0.6; opacity: 0.6;
} }
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */ /* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
/* 내역 추가/수정 인라인 페이지 (헤더·하단바 유지) */
.entry-page {
max-width: 640px;
margin: 0 auto;
}
.ep-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.ep-title {
font-size: 1.2rem;
font-weight: 700;
}
.entry-page .entry-form {
max-width: 460px;
margin: 0 auto;
}
.entry-backdrop { .entry-backdrop {
padding: 0; padding: 0;
} }
@@ -1699,9 +2014,12 @@ button.primary {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 360px; max-width: 360px;
/* backdrop 이 flex 라 모달(flex 아이템) 기본 min-width:auto 가 내용물만큼 모달을 뷰포트보다 넓힘 → 0 으로 고정 */
min-width: 0;
/* 폼이 길어도 모달 안에서 스크롤되도록 + 하단 소프트키/제스처바 안전영역 확보 */ /* 폼이 길어도 모달 안에서 스크롤되도록 + 하단 소프트키/제스처바 안전영역 확보 */
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; /* 내부 요소가 가로로 넘쳐도 페이지가 밀리지 않게(넓은 그리드·입력행 방지) */
padding: 1.75rem 1.5rem calc(1.5rem + env(safe-area-inset-bottom)); padding: 1.75rem 1.5rem calc(1.5rem + env(safe-area-inset-bottom));
background: var(--color-background); background: var(--color-background);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -1718,6 +2036,11 @@ button.primary {
background: transparent; background: transparent;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
z-index: 1;
}
/* 전체화면 모달(entry)만: 닫기 버튼을 상태바/노치 아래로 — 중앙 모달엔 적용 안 함 */
.modal.entry-modal .close {
top: calc(0.5rem + env(safe-area-inset-top));
} }
.modal h2 { .modal h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -2014,6 +2337,11 @@ button.primary {
font-size: 0.9rem; font-size: 0.9rem;
padding-bottom: 0.45rem; padding-bottom: 0.45rem;
} }
.fx-age {
font-size: 0.7rem;
color: var(--color-text-soft, #999);
margin-top: 0.18rem;
}
.row-wallet { .row-wallet {
margin-right: 0.35rem; margin-right: 0.35rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -2115,6 +2443,32 @@ button.primary {
font: inherit; font: inherit;
resize: vertical; resize: vertical;
} }
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
.paste-modal .pm-buttons {
bottom: 0;
gap: 0.6rem;
padding-bottom: calc(0.2rem + env(safe-area-inset-bottom));
}
.pm-btn {
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
border: 1px solid var(--color-border);
}
.pm-cancel {
flex: 0 0 auto;
min-width: 84px;
background: var(--color-background-soft);
color: var(--color-text);
}
.pm-primary {
flex: 1;
background: hsla(160, 100%, 37%, 1);
border-color: hsla(160, 100%, 37%, 1);
color: #fff;
}
.to-recurring { .to-recurring {
display: block; display: block;
width: 100%; width: 100%;
@@ -0,0 +1,301 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const walletId = Number(route.params.id)
const wallet = ref(null)
const entries = ref([])
const loading = ref(false)
const error = ref(null)
const month = ref('') // YYYY-MM
const TYPE_LABEL = {
BANK: '은행계좌',
CASH: '현금',
CARD: '신용/체크카드',
LOAN: '대출',
MINUS: '마이너스통장',
INVEST: '투자',
}
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function currentYm() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function ymLabel(ym) {
return ym ? ym.replace('-', '.') : ''
}
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
// (+/, -/)
function effectAmount(e) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e) {
const m = e.memo ? ` · ${e.memo}` : ''
const w = wallet.value
if (e.type === 'TRANSFER') {
if (e.toWalletId === walletId) {
const inLabel = w && isLiability(w.type) ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
const monthListArr = computed(() => {
const set = new Set(entries.value.map((e) => (e.entryDate || '').slice(0, 7)).filter(Boolean))
return [...set].sort()
})
const monthEntries = computed(() =>
entries.value.filter((e) => (e.entryDate || '').slice(0, 7) === month.value),
)
const monthSum = computed(() => monthEntries.value.reduce((s, e) => s + effectAmount(e), 0))
const canPrev = computed(() => monthListArr.value.indexOf(month.value) > 0)
const canNext = computed(() => {
const i = monthListArr.value.indexOf(month.value)
return i >= 0 && i < monthListArr.value.length - 1
})
function shiftMonth(delta) {
const ms = monthListArr.value
if (!ms.length) return
const i = ms.indexOf(month.value)
const j = Math.min(ms.length - 1, Math.max(0, (i === -1 ? ms.length - 1 : i) + delta))
month.value = ms[j]
}
async function load() {
loading.value = true
error.value = null
try {
const ws = await accountApi.wallets()
wallet.value = ws.find((w) => w.id === walletId) || null
if (!wallet.value) {
error.value = '계좌를 찾을 수 없습니다.'
return
}
if (wallet.value.type !== 'INVEST') {
entries.value = await accountApi.walletEntries(walletId)
const ms = monthListArr.value
month.value = ms.length ? ms[ms.length - 1] : currentYm()
}
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section class="wdetail">
<header class="wd-head">
<span class="wd-title">{{ wallet?.name || '계좌' }}</span>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-else-if="loading" class="msg">불러오는 중...</p>
<template v-else-if="wallet">
<!-- 계좌 요약 카드 -->
<div class="wd-summary">
<div class="wd-sum-top">
<span class="wd-type">{{ TYPE_LABEL[wallet.type] }}</span>
<span v-if="wallet.type === 'CARD' && wallet.cardType" class="wd-badge">{{ cardTypeLabel(wallet.cardType) }}</span>
</div>
<div class="wd-figure" :class="{ neg: wallet.type === 'INVEST' ? false : wallet.balance < 0 }">
<template v-if="wallet.type === 'INVEST'">투자금 {{ won(wallet.investedAmount) }}</template>
<template v-else>{{ won(wallet.balance) }}</template>
</div>
<div class="wd-meta">
<span v-if="wallet.issuer">{{ wallet.issuer }}</span>
<span v-if="wallet.type === 'LOAN' && wallet.loanRate">
· {{ wallet.loanRate }}%<template v-if="wallet.loanMethod"> / {{ loanMethodLabel(wallet.loanMethod) }}</template>
</span>
</div>
</div>
<!-- 투자: 내역 대신 안내 -->
<div v-if="wallet.type === 'INVEST'" class="wd-invest-note">
<p>투자금(투입원금) 표시합니다. 금액 변경은 계좌 관리에서 <b>수정</b>하세요.</p>
</div>
<!-- : 월별 내역 -->
<template v-else>
<div v-if="monthListArr.length" class="wd-month-nav">
<button type="button" class="mn-btn" :disabled="!canPrev" aria-label="이전 " @click="shiftMonth(-1)"></button>
<span class="mn-label">{{ ymLabel(month) }}</span>
<button type="button" class="mn-btn" :disabled="!canNext" aria-label="다음 " @click="shiftMonth(1)"></button>
<span class="mn-sum" :class="monthSum < 0 ? 'neg' : 'pos'">
합계 {{ monthSum >= 0 ? '+' : '' }}{{ won(monthSum) }}
</span>
</div>
<ul v-if="monthEntries.length" class="wd-list">
<li v-for="e in monthEntries" :key="e.id" class="wd-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e) }}</span>
<span class="d-amount" :class="effectAmount(e) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e) >= 0 ? '+' : '' }}{{ won(effectAmount(e)) }}
</span>
</li>
</ul>
<p v-else-if="monthListArr.length" class="msg"> 내역이 없습니다.</p>
<p v-else class="msg">연관 내역이 없습니다.</p>
</template>
</template>
</section>
</template>
<style scoped>
.wdetail {
max-width: 760px;
margin: 0 auto;
}
.wd-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.wd-title {
font-size: 1.2rem;
font-weight: 700;
}
.msg {
margin: 0.75rem 0;
opacity: 0.8;
}
.msg.error {
color: #c0392b;
}
.wd-summary {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.wd-sum-top {
display: flex;
align-items: center;
gap: 0.4rem;
}
.wd-type {
font-size: 0.85rem;
opacity: 0.7;
}
.wd-badge {
font-size: 0.72rem;
padding: 0.05rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.wd-figure {
font-size: 1.6rem;
font-weight: 800;
margin-top: 0.25rem;
}
.wd-figure.neg {
color: #c0392b;
}
.wd-meta {
margin-top: 0.3rem;
font-size: 0.85rem;
opacity: 0.65;
}
.wd-invest-note {
font-size: 0.9rem;
opacity: 0.75;
line-height: 1.5;
}
.wd-month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
}
.mn-btn {
border: 1px solid var(--color-border);
background: var(--color-background-soft);
border-radius: 6px;
width: 2rem;
height: 2rem;
cursor: pointer;
color: var(--color-text);
}
.mn-btn:disabled {
opacity: 0.4;
cursor: default;
}
.mn-label {
font-weight: 700;
}
.mn-sum {
margin-left: auto;
font-weight: 700;
}
.mn-sum.neg,
.d-amount.neg {
color: #c0392b;
}
.mn-sum.pos,
.d-amount.pos {
color: #2e7d32;
}
.wd-list {
list-style: none;
margin: 0;
padding: 0;
}
.wd-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.d-date {
flex: 0 0 auto;
font-size: 0.82rem;
opacity: 0.6;
width: 3rem;
}
.d-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.d-amount {
flex: 0 0 auto;
font-weight: 600;
font-size: 0.9rem;
}
</style>
+118 -414
View File
@@ -1,12 +1,15 @@
<script setup> <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 Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
const dialog = useDialog() const dialog = useDialog()
const auth = useAuthStore()
const router = useRouter()
const TABS = [ const TABS = [
{ key: 'BANK', label: '은행계좌' }, { key: 'BANK', label: '은행계좌' },
@@ -16,125 +19,67 @@ const TABS = [
{ key: 'MINUS', label: '마이너스통장' }, { key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' }, { key: 'INVEST', label: '투자' },
] ]
const CARD_TYPES = [
{ value: 'CREDIT', label: '신용카드' },
{ value: 'CHECK', label: '체크카드' },
]
const LOAN_METHODS = [
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
{ value: 'BULLET', label: '만기일시' },
{ value: '', label: '선택안함' },
]
const wallets = ref([]) const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 }) const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false) const loading = ref(false)
const error = ref(null) 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) function goDetail(w) {
// openingBalance ( ). . router.push(`/account/wallets/${w.id}`)
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
// (LOAN)
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
const submitting = ref(false)
const formError = ref(null)
// ( )
const expandedId = ref(null)
const entriesByWallet = reactive({})
const loadingEntries = ref(false)
const entryMonth = reactive({}) // (YYYY-MM)
function currentYm() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
} }
function ymLabel(ym) { // / ( )
return ym ? ym.replace('-', '.') : '' function openCreate() {
router.push('/account/wallets/new')
} }
// () function openEdit(w) {
function monthList(w) { router.push(`/account/wallets/${w.id}/edit`)
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]
} }
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS' // ===== ( SortableJS, ) =====
let sortables = []
// (activeType) ( sort_order ) function destroySortables() {
const rows = ref([]) sortables.forEach((s) => s.destroy())
function syncRows() { sortables = []
rows.value = wallets.value.filter((w) => w.type === activeType.value)
} }
watch([wallets, activeType], syncRows, { immediate: true }) function initSortables() {
destroySortables()
// ( ) if (!reorderMode.value) return //
function selectTab(key) { document.querySelectorAll('.wallet-group-list').forEach((el) => {
activeType.value = key sortables.push(
} Sortable.create(el, {
// ===== (SortableJS, ) =====
const listEl = ref(null)
let sortable = null
function initSortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
if (!listEl.value) return
sortable = Sortable.create(listEl.value, {
handle: '.drag-handle', handle: '.drag-handle',
animation: 150, animation: 150,
onEnd: (evt) => { onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return if (evt.oldIndex === evt.newIndex) return
const arr = rows.value const type = el.dataset.type
const moved = arr.splice(evt.oldIndex, 1)[0] const ids = [...el.querySelectorAll(':scope > .wallet-item')].map((li) => Number(li.dataset.id))
arr.splice(evt.newIndex, 0, moved) // : Sortable DOM (Vue )
persistOrder(arr.map((r) => r.id)) 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 { try {
await accountApi.reorderWallets(activeType.value, ids) await accountApi.reorderWallets(type, ids)
await load() await load()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.') alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
@@ -142,52 +87,6 @@ async function persistOrder(ids) {
} }
} }
async function toggleExpand(w) {
if (expandedId.value === w.id) {
expandedId.value = null
return
}
expandedId.value = w.id
if (w.type === 'INVEST') return //
loadingEntries.value = true
try {
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
// = ( )
const ms = monthList(w)
entryMonth[w.id] = ms.length ? ms[ms.length - 1] : currentYm()
} catch {
entriesByWallet[w.id] = []
} finally {
loadingEntries.value = false
}
}
// (+/, -/)
function effectAmount(e, walletId) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e, w) {
const m = e.memo ? ` · ${e.memo}` : ''
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// : /, (·)
const inLabel = w.type === 'CARD' || w.type === 'LOAN' || w.type === 'MINUS' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
function won(n) { function won(n) {
return (n ?? 0).toLocaleString('ko-KR') return (n ?? 0).toLocaleString('ko-KR')
} }
@@ -210,22 +109,6 @@ function toggleReveal(id) {
s.has(id) ? s.delete(id) : s.add(id) s.has(id) ? s.delete(id) : s.add(id)
revealedAccts.value = s revealedAccts.value = s
} }
function issuerLabel(t) {
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
if (t === 'CARD') return '초기 미결제 금액'
if (t === 'INVEST') return '투자금(투입원금)'
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
return '기록 시작 시 잔액'
}
// (%)
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -238,88 +121,9 @@ async function load() {
} finally { } finally {
loading.value = false loading.value = false
} }
} //
await nextTick()
function openCreate() { initSortables()
editId.value = null
Object.assign(form, {
type: activeType.value,
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// ( )
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// ( )
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
} }
async function remove(w) { async function remove(w) {
@@ -339,12 +143,8 @@ function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : '' return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
} }
onMounted(async () => { onMounted(load)
await load() onBeforeUnmount(destroySortables)
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
</script> </script>
<template> <template>
@@ -366,30 +166,32 @@ onBeforeUnmount(() => sortable?.destroy())
</div> </div>
</div> </div>
<div class="tabbar"> <div class="wallet-head">
<div class="tabs">
<button <button
v-for="t in TABS" v-if="wallets.length > 1"
:key="t.key" type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }"
type="button" @click="reorderMode = !reorderMode"
:class="{ active: activeType === t.key }" >{{ reorderMode ? '완료' : '순서변경' }}</button>
@click="selectTab(t.key)" <IconBtn class="tab-add" icon="plus" title="계좌 추가" variant="primary" @click="openCreate" />
>{{ t.label }}</button>
</div>
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
</div> </div>
<p v-if="reorderMode" class="msg reorder-hint"> 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b> 누르세요.</p>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && rows.length" ref="listEl" class="wallet-list"> <template v-if="!loading && groups.length">
<li v-for="w in rows" :key="w.id" :data-id="w.id" class="wallet-item"> <section v-for="g in groups" :key="g.key" class="wallet-group">
<div class="wallet-row" @click="toggleExpand(w)"> <h3 class="wg-title">{{ g.label }}</h3>
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop></span> <ul class="wallet-list wallet-group-list" :data-type="g.key">
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span> <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="info">
<div class="line1"> <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> <span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div> </div>
<span class="sub"> <span class="sub">
@@ -402,7 +204,6 @@ onBeforeUnmount(() => sortable?.destroy())
@click.stop="toggleReveal(w.id)" @click.stop="toggleReveal(w.id)"
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button> >{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template> </template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'"> <template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span> <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> <span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
@@ -410,134 +211,26 @@ onBeforeUnmount(() => sortable?.destroy())
</span> </span>
</div> </div>
<div class="balance-wrap"> <div class="balance-wrap">
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div> <div v-if="w.type === 'INVEST'" class="balance">{{ won(w.investedAmount) }}</div>
<div <div v-else class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
v-if="w.type === 'INVEST' && w.valuationGain != null"
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
>
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</div> </div>
</div> <div v-if="!reorderMode" class="actions">
<div class="actions" @click.stop>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" /> <IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div> </div>
</div> </div>
</li>
</ul>
</section>
</template>
<!-- 드롭다운: 투자는 투자금·평가액 요약, 외는 계좌별 내역 --> <div v-else-if="!loading" class="empty-state">
<div v-if="expandedId === w.id" class="entry-drop">
<div v-if="w.type === 'INVEST'" class="manual-note">
<div class="invest-figs">
<div class="fig"><span class="fig-k">투자금</span><span class="fig-v">{{ won(w.investedAmount) }}</span></div>
<div class="fig"><span class="fig-k">평가액</span><span class="fig-v">{{ won(w.balance) }}</span></div>
<div class="fig"><span class="fig-k">손익</span>
<span class="fig-v" :class="(w.valuationGain || 0) < 0 ? 'neg' : 'pos'">
{{ (w.valuationGain || 0) >= 0 ? '+' : '' }}{{ won(w.valuationGain || 0) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</span>
</div>
</div>
<p class="manual-sub">평가액을 갱신하려면 <b>수정</b>에서 현재 평가액 바꾸세요.</p>
</div>
<template v-else>
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
<template v-else-if="(entriesByWallet[w.id] || []).length">
<!-- 네비게이션 + 합계 -->
<div class="month-nav">
<button type="button" class="mn-btn" :disabled="!canPrev(w)" aria-label="이전 " @click="shiftMonth(w, -1)"></button>
<span class="mn-label">{{ ymLabel(entryMonth[w.id]) }}</span>
<button type="button" class="mn-btn" :disabled="!canNext(w)" aria-label="다음 " @click="shiftMonth(w, 1)"></button>
<span class="mn-sum" :class="monthSum(w) < 0 ? 'neg' : 'pos'">
합계 {{ monthSum(w) >= 0 ? '+' : '' }}{{ won(monthSum(w)) }}
</span>
</div>
<ul v-if="monthEntries(w).length" class="drop-list">
<li v-for="e in monthEntries(w)" :key="e.id" class="drop-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e, w) }}</span>
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
</span>
</li>
</ul>
<p v-else class="drop-msg"> 내역이 없습니다.</p>
</template>
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
</template>
</div>
</li>
</ul>
<div v-if="!loading && !rows.length" class="empty-state">
<p class="empty-emoji">🏦</p> <p class="empty-emoji">🏦</p>
<p class="empty-title">아직 등록된 계좌가 없어요</p> <p class="empty-title">아직 등록된 계좌가 없어요</p>
<p class="empty-desc">은행·카드·현금·대출·마이너스통장·투자를 추가해보세요.</p> <p class="empty-desc">은행·카드·현금·대출·마이너스통장·투자를 추가해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 계좌 추가</button> <button type="button" class="empty-cta" @click="openCreate"> 계좌 추가</button>
</div> </div>
<!-- 추가/수정 모달 -->
<AppModal v-model="formOpen" :title="`${TABS.find((t) => t.key === form.type)?.label} ${editId ? '수정' : '추가'}`">
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="form.type === 'CARD'" class="field">
<span class="field-label">카드 종류</span>
<div class="chip-group">
<button
v-for="c in CARD_TYPES" :key="c.value"
type="button" class="chip" :class="{ active: form.cardType === c.value }"
:disabled="submitting" @click="form.cardType = c.value"
>{{ c.label }}</button>
</div>
</div>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
</label>
<p class="form-hint">
<b>투자금</b>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
placeholder="예: 5.25" :disabled="submitting" />
</label>
<div class="field">
<span class="field-label">상환방식</span>
<div class="chip-group">
<button
v-for="m in LOAN_METHODS" :key="m.value"
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
:disabled="submitting" @click="form.loanMethod = m.value"
>{{ m.label }}</button>
</div>
</div>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</AppModal>
</section> </section>
</template> </template>
@@ -603,42 +296,46 @@ button.primary {
.nw-card .value.debt { .nw-card .value.debt {
color: #c0392b; color: #c0392b;
} }
.tabbar { .wallet-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
border-bottom: 1px solid var(--color-border); margin-bottom: 0.75rem;
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;
} }
.tab-add { .tab-add {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
} }
.tabs button { .text-btn {
border: 0; border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent; background: transparent;
padding: 0.6rem 0.7rem; color: var(--color-text);
white-space: nowrap; /* 버튼 안 글자가 줄바꿈되지 않도록(두 줄 방지) */ opacity: 0.75;
flex-shrink: 0; font-size: 0.88rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
} }
.tabs button.active { .reorder-btn.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1); 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 { .wallet-list {
list-style: none; list-style: none;
@@ -653,10 +350,6 @@ button.primary {
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
padding: 0.7rem 0; padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
} }
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
@@ -810,6 +503,13 @@ button.primary {
.name { .name {
font-weight: 600; font-weight: 600;
} }
.name.link {
cursor: pointer;
color: hsla(160, 100%, 37%, 1);
}
.name.link:active {
opacity: 0.7;
}
.badge { .badge {
font-size: 0.72rem; font-size: 0.72rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -905,6 +605,10 @@ button.primary {
.msg.error { .msg.error {
color: #c0392b; color: #c0392b;
} }
.msg.wallet-limit {
color: #b8860b;
font-size: 0.85rem;
}
.wallet-form { .wallet-form {
display: flex; display: flex;
+97 -253
View File
@@ -1,12 +1,15 @@
<script setup> <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 { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue' import CategoryPicker from '@/components/ui/CategoryPicker.vue'
import BottomSheet from '@/components/ui/BottomSheet.vue'
const dialog = useDialog() const dialog = useDialog()
const route = useRoute()
const router = useRouter()
const now = new Date() const now = new Date()
const year = ref(now.getFullYear()) const year = ref(now.getFullYear())
@@ -17,20 +20,28 @@ const budgetsRaw = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// (?budget) ( )
const formOpen = ref(false) 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 editId = ref(null)
const form = reactive({ const form = reactive({
category: '', category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null, monthly: null,
yearly: null,
}) })
const submitting = ref(false) const submitting = ref(false)
const formError = ref(null) const formError = ref(null)
//
const catSheet = ref(false)
watch(() => form.category, (v) => { if (v) catSheet.value = false })
// () // ()
const categories = ref([]) const categories = ref([])
@@ -46,28 +57,6 @@ const periodLabel = computed(() => `${year.value}년 ${String(month.value).padSt
function won(n) { function won(n) {
return (n ?? 0).toLocaleString('ko-KR') 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) { function ratio(s) {
if (!s.monthlyBudget) return 0 if (!s.monthlyBudget) return 0
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999) return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
@@ -178,35 +167,17 @@ async function copyFromPrevMonth() {
function openCreate() { function openCreate() {
editId.value = null editId.value = null
Object.assign(form, { Object.assign(form, { category: '', monthly: null })
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
formError.value = null formError.value = null
formOpen.value = true openForm()
} }
function openEdit(s) { function openEdit(s) {
const b = budgetsRaw.value.find((x) => x.id === s.id) const b = budgetsRaw.value.find((x) => x.id === s.id)
if (!b) return if (!b) return
editId.value = b.id editId.value = b.id
Object.assign(form, { Object.assign(form, { category: b.category, monthly: b.monthly })
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,
})
formError.value = null formError.value = null
formOpen.value = true openForm()
} }
async function submit() { async function submit() {
@@ -215,28 +186,23 @@ async function submit() {
formError.value = '카테고리를 입력하세요.' formError.value = '카테고리를 입력하세요.'
return return
} }
let payload if (!form.monthly || form.monthly <= 0) {
if (form.fixed) { formError.value = '월 예산 금액을 입력하세요.'
if (!form.baseAmount || form.baseAmount <= 0) {
formError.value = '기준 금액을 입력하세요.'
return return
} }
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) } const payload = {
} else {
payload = {
category: form.category.trim(), category: form.category.trim(),
fixed: false, fixed: false,
daily: form.daily != null ? Number(form.daily) : null, daily: null,
weekly: form.weekly != null ? Number(form.weekly) : null, weekly: null,
monthly: form.monthly != null ? Number(form.monthly) : null, monthly: Number(form.monthly),
yearly: form.yearly != null ? Number(form.yearly) : null, yearly: null,
}
} }
submitting.value = true submitting.value = true
try { try {
if (editId.value) await accountApi.updateBudget(editId.value, payload) if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload, { year: year.value, month: month.value }) else await accountApi.createBudget(payload, { year: year.value, month: month.value })
formOpen.value = false closeForm()
await load() await load()
} catch (e) { } catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.' formError.value = e.response?.data?.message || '저장에 실패했습니다.'
@@ -255,102 +221,16 @@ async function remove(s) {
} }
} }
// ===== 1 =====
const onetimes = ref([])
const onetimeFormOpen = ref(false)
const onetimeEditId = ref(null)
const onetimeForm = reactive({ title: '', category: '', amount: null, startDate: '', endDate: '' })
const onetimeSubmitting = ref(false)
const onetimeFormError = ref(null)
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function loadOnetime() {
try { onetimes.value = await accountApi.onetimeBudgets() }
catch { onetimes.value = [] }
}
function statusLabel(s) {
return s === 'UPCOMING' ? '예정' : s === 'ACTIVE' ? '진행' : '종료'
}
function onetimeRatio(o) {
if (!o.amount) return 0
return Math.min(Math.round((o.spent / o.amount) * 100), 999)
}
function onetimeBarClass(o) {
const r = o.amount ? o.spent / o.amount : 0
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
}
function openOnetimeCreate() {
onetimeEditId.value = null
Object.assign(onetimeForm, { title: '', category: '', amount: null, startDate: todayStr(), endDate: '' })
onetimeFormError.value = null
onetimeFormOpen.value = true
}
function openOnetimeEdit(o) {
onetimeEditId.value = o.id
Object.assign(onetimeForm, {
title: o.title,
category: o.category || '',
amount: o.amount,
startDate: o.startDate,
endDate: o.endDate,
})
onetimeFormError.value = null
onetimeFormOpen.value = true
}
async function submitOnetime() {
onetimeFormError.value = null
if (!onetimeForm.title.trim()) { onetimeFormError.value = '제목을 입력하세요.'; return }
if (!onetimeForm.amount || onetimeForm.amount <= 0) { onetimeFormError.value = '금액을 입력하세요.'; return }
if (!onetimeForm.startDate) { onetimeFormError.value = '시작일을 입력하세요.'; return }
if (!onetimeForm.endDate) { onetimeFormError.value = '종료일을 입력하세요.'; return }
if (onetimeForm.startDate > onetimeForm.endDate) { onetimeFormError.value = '종료일이 시작일보다 빠를 수 없습니다.'; return }
const payload = {
title: onetimeForm.title.trim(),
category: onetimeForm.category || null,
amount: Number(onetimeForm.amount),
startDate: onetimeForm.startDate,
endDate: onetimeForm.endDate,
}
onetimeSubmitting.value = true
try {
if (onetimeEditId.value) await accountApi.updateOnetimeBudget(onetimeEditId.value, payload)
else await accountApi.createOnetimeBudget(payload)
onetimeFormOpen.value = false
await loadOnetime()
} catch (e) {
onetimeFormError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
onetimeSubmitting.value = false
}
}
async function removeOnetime(o) {
if (!(await dialog.confirm(`'${o.title}' 1회성 예산을 삭제할까요?`, { title: '삭제', danger: true }))) return
try {
await accountApi.removeOnetimeBudget(o.id)
await loadOnetime()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(() => { onMounted(() => {
load() load()
loadCategories() loadCategories()
loadIncome() loadIncome()
loadOnetime()
}) })
</script> </script>
<template> <template>
<section class="budget"> <section class="budget">
<template v-if="!formOpen">
<div class="month-nav"> <div class="month-nav">
<span class="mn-spacer"></span> <span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" /> <IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
@@ -417,77 +297,25 @@ onMounted(() => {
</li> </li>
</ul> </ul>
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p> <p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p>
</template>
<!-- 1회성 예산 섹션 --> <!-- 예산 추가/수정 (인라인 페이지 헤더·하단바 유지, 뒤로가기로 닫힘) -->
<div class="onetime-section"> <div v-else class="budget-page">
<div class="onetime-header"> <header class="ep-head">
<span class="onetime-title">1회성 예산</span> <span class="ep-title">예산 {{ editId ? '수정' : '추가' }}</span>
<IconBtn icon="plus" title="1회성 예산 추가" variant="primary" size="sm" @click="openOnetimeCreate" /> </header>
</div>
<ul v-if="onetimes.length" class="onetime-list">
<li v-for="o in onetimes" :key="o.id" class="onetime-row">
<div class="ot-top">
<span class="ot-name">{{ o.title }}</span>
<span class="ot-status" :class="o.status.toLowerCase()">{{ statusLabel(o.status) }}</span>
<span class="ot-amounts">
<span :class="o.remaining < 0 ? 'neg' : ''">{{ won(o.spent) }}</span>
<span class="ot-sep">/</span>
{{ won(o.amount) }}
</span>
</div>
<div class="bar">
<div class="bar-fill" :class="onetimeBarClass(o)" :style="{ width: Math.min(onetimeRatio(o), 100) + '%' }"></div>
</div>
<div class="ot-bottom">
<span class="ot-date">{{ o.startDate }} ~ {{ o.endDate }}</span>
<span v-if="o.category" class="ot-cat">{{ o.category }}</span>
<span class="ot-remain" :class="o.remaining < 0 ? 'neg' : ''">
{{ o.remaining >= 0 ? `잔여 ${won(o.remaining)}` : `초과 ${won(-o.remaining)}` }}
</span>
<span class="acts">
<IconBtn icon="edit" title="수정" size="sm" @click="openOnetimeEdit(o)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeOnetime(o)" />
</span>
</div>
</li>
</ul>
<p v-else class="msg ot-empty">등록된 1회성 예산이 없습니다.</p>
</div>
<!-- 1회성 예산 추가/수정 모달 -->
<AppModal v-model="onetimeFormOpen" :title="`1회성 예산 ${onetimeEditId ? '수정' : '추가'}`">
<form class="budget-form" @submit.prevent="submitOnetime">
<label>제목<input v-model="onetimeForm.title" type="text" placeholder="예: 7월 여행, 추석 준비" :disabled="onetimeSubmitting" /></label>
<div class="field">
<span class="field-label">카테고리 <span class="field-hint">(선택 비우면 전체 지출)</span></span>
<CategoryPicker
v-model="onetimeForm.category"
type="EXPENSE"
:categories="categories"
:disabled="onetimeSubmitting"
@category-added="loadCategories"
/>
</div>
<label>예산 금액<input v-model.number="onetimeForm.amount" type="number" min="1" placeholder="원" :disabled="onetimeSubmitting" /></label>
<div class="date-range">
<label>시작일<input v-model="onetimeForm.startDate" type="date" :disabled="onetimeSubmitting" /></label>
<span class="range-sep">~</span>
<label>종료일<input v-model="onetimeForm.endDate" type="date" :disabled="onetimeSubmitting" /></label>
</div>
<p v-if="onetimeFormError" class="msg error">{{ onetimeFormError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="onetimeFormOpen = false" />
<IconBtn icon="save" :title="onetimeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="onetimeSubmitting" />
</div>
</form>
</AppModal>
<!-- 추가/수정 모달 (월별) -->
<AppModal v-model="formOpen" :title="`예산 ${editId ? '수정' : '추가'}`">
<form class="budget-form" @submit.prevent="submit"> <form class="budget-form" @submit.prevent="submit">
<div class="field"> <div class="field">
<span class="field-label">카테고리</span> <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 <CategoryPicker
v-model="form.category" v-model="form.category"
type="EXPENSE" type="EXPENSE"
@@ -495,49 +323,19 @@ onMounted(() => {
:disabled="submitting" :disabled="submitting"
@category-added="loadCategories" @category-added="loadCategories"
/> />
</div> </BottomSheet>
<div class="field">
<span class="field-label">예산 방식</span>
<div class="chip-group">
<button type="button" class="chip" :class="{ active: !form.fixed }" :disabled="submitting" @click="form.fixed = false">비고정 (기간별 직접)</button>
<button type="button" class="chip" :class="{ active: form.fixed }" :disabled="submitting" @click="form.fixed = true">고정 (자동 환산)</button>
</div>
</div>
<!-- 고정 -->
<template v-if="form.fixed">
<div class="fixed-base">
<select v-model="form.baseUnit" :disabled="submitting">
<option value="DAY"></option>
<option value="WEEK"></option>
<option value="MONTH"></option>
</select>
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
</div>
<p v-if="fixedPreview" class="preview">
환산 {{ won(fixedPreview.weekly) }} · {{ won(fixedPreview.monthly) }} · {{ won(fixedPreview.yearly) }}
<span class="note">(이번 {{ daysInMonth(year, month) }} / 올해 {{ daysInYear(year) }} 기준)</span>
</p>
</template>
<!-- 비고정 -->
<template v-else>
<label> 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label> <label> 예산<input v-model.number="form.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>
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p> <p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons"> <div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" /> <IconBtn icon="close" title="취소" @click="closeForm()" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" /> <IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div> </div>
</form> </form>
</AppModal> </div>
</section> </section>
</template> </template>
@@ -546,6 +344,52 @@ onMounted(() => {
max-width: 640px; max-width: 640px;
margin: 0 auto; 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 { .head {
display: flex; display: flex;
align-items: center; align-items: center;
+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>
+34 -313
View File
@@ -1,93 +1,41 @@
<script setup> <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 { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
const dialog = useDialog() const dialog = useDialog()
const auth = useAuthStore()
const router = useRouter()
const recurrings = ref([]) 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 loading = ref(false)
const error = ref(null) const error = ref(null)
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({
title: '',
type: 'EXPENSE',
amount: null,
category: '',
memo: '',
walletKind: '',
walletId: '',
toWalletKind: '',
toWalletId: '',
frequency: 'MONTHLY',
dayOfMonth: 1,
dayOfWeek: 1,
monthOfYear: 1,
startDate: '',
endDate: '',
active: true,
})
const submitting = ref(false)
const formError = ref(null)
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
const TYPES = [
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
]
const FREQS = [
{ value: 'DAILY', label: '매일' },
{ value: 'WEEKLY', label: '매주' },
{ value: 'MONTHLY', label: '매월' },
{ value: 'YEARLY', label: '매년' },
]
// /
const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' },
{ value: 'CASH', label: '현금' },
{ value: 'CARD', label: '카드' },
{ value: 'MINUS', label: '마이너스' },
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id)
return w ? w.type : ''
}
function onWalletKindChange() { form.walletId = '' }
function onToWalletKindChange() { form.toWalletId = '' }
function onTypeChange() { form.category = '' }
function won(n) { return (n ?? 0).toLocaleString('ko-KR') } function won(n) { return (n ?? 0).toLocaleString('ko-KR') }
function typeLabel(t) { function typeLabel(t) {
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출' return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
} }
function freqLabel(r) { function freqLabel(r) {
if (r.frequency === 'DAILY') return '매일'
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}` if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}`
return `매년 ${r.monthOfYear}/${r.dayOfMonth}` return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
} }
function payKey(r) { 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) if (r.frequency === 'YEARLY') return (r.monthOfYear || 0) * 100 + (r.dayOfMonth || 0)
return r.dayOfMonth || 0 return r.dayOfMonth || 0
} }
const PERIODS = [ const PERIODS = [
{ key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] }, { key: 'MONTH', label: '월간 거래', freqs: ['MONTHLY'] },
{ key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] }, { key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
] ]
const grouped = computed(() => const grouped = computed(() =>
@@ -109,23 +57,11 @@ const grouped = computed(() =>
}).filter((g) => g.count > 0), }).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() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const [recs, ws, cats] = await Promise.all([ recurrings.value = await accountApi.recurrings()
accountApi.recurrings(),
accountApi.wallets(),
accountApi.categories(),
])
recurrings.value = recs
wallets.value = ws
categories.value = cats
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.' error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally { } finally {
@@ -136,78 +72,30 @@ async function load() {
async function runNow() { async function runNow() {
try { try {
const res = await accountApi.runRecurrings() const res = await accountApi.runRecurrings()
alert(`${res.generated}건의 고정 지출가 반영되었습니다.`) alert(`${res.generated}건의 정기결제가 반영되었습니다.`)
await load() await load()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '반영에 실패했습니다.') alert(e.response?.data?.message || '반영에 실패했습니다.')
} }
} }
function openCreate() { async function openCreate() {
editId.value = null if (atRecurringLimit.value) {
Object.assign(form, { const go = await dialog.confirm(
title: '', type: 'EXPENSE', amount: null, category: '', memo: '', `무료 회원은 정기결제를 ${FREE_RECURRING_LIMIT}개까지 등록할 수 있어요.\n유료로 업그레이드하면 무제한이에요.`,
walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', { title: '등록 한도', okText: '업그레이드', cancelText: '닫기' },
frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, )
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true, if (go) router.push('/upgrade')
})
formError.value = null
formOpen.value = true
}
function openEdit(r) {
editId.value = r.id
Object.assign(form, {
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '',
frequency: r.frequency,
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.title.trim()) { formError.value = '이름을 입력하세요.'; return }
if (!form.amount || form.amount <= 0) { formError.value = '금액을 입력하세요.'; return }
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
return return
} }
if (!form.startDate) { formError.value = '시작일을 입력하세요.'; return } router.push('/account/recurrings/new')
submitting.value = true }
const payload = { function openEdit(r) {
title: form.title.trim(), router.push(`/account/recurrings/${r.id}/edit`)
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
}
} }
async function remove(r) { 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 { try {
await accountApi.removeRecurring(r.id) await accountApi.removeRecurring(r.id)
await load() await load()
@@ -224,8 +112,12 @@ onMounted(load)
<p class="hint">등록한 주기에 맞춰 가계부 진입 자동으로 <b>'확인 필요'</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 <b>'확인'</b> 눌러 확정하세요.</p> <p class="hint">등록한 주기에 맞춰 가계부 진입 자동으로 <b>'확인 필요'</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 <b>'확인'</b> 눌러 확정하세요.</p>
<div class="recur-actions"> <div class="recur-actions">
<IconBtn icon="refresh" title="지금 반영" @click="runNow" /> <IconBtn icon="refresh" title="지금 반영" @click="runNow" />
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" /> <IconBtn icon="plus" title="정기결제 추가" variant="primary" @click="openCreate" />
</div> </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="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
@@ -261,117 +153,7 @@ onMounted(load)
</div> </div>
</section> </section>
</div> </div>
<p v-else-if="!loading" class="msg">등록된 고정 지출 없습니다.</p> <p v-else-if="!loading" class="msg">등록된 정기결제 없습니다.</p>
<!-- 추가/수정 모달 -->
<AppModal v-model="formOpen" :title="`고정 지출 ${editId ? '수정' : '추가'}`">
<form class="recur-form" @submit.prevent="submit">
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
<!-- 구분 배지 -->
<div class="field">
<span class="field-label">구분</span>
<div class="type-chips">
<button
v-for="t in TYPES" :key="t.value"
type="button" class="chip" :class="[t.cls, { active: form.type === t.value }]"
:disabled="submitting"
@click="form.type = t.value; onTypeChange()"
>{{ t.label }}</button>
</div>
</div>
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<!-- 출금 계좌 종류 배지 -->
<div class="field">
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
<div class="wallet-chips">
<button
v-for="k in WALLET_KINDS" :key="k.value"
type="button" class="chip" :class="{ active: form.walletKind === k.value }"
:disabled="submitting"
@click="form.walletKind = k.value; onWalletKindChange()"
>{{ k.label }}</button>
</div>
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
<!-- 입금 계좌 종류 배지 (이체일 때만) -->
<div v-if="form.type === 'TRANSFER'" class="field">
<span class="field-label">입금 계좌</span>
<div class="wallet-chips">
<button
v-for="k in WALLET_KINDS" :key="k.value"
type="button" class="chip" :class="{ active: form.toWalletKind === k.value }"
:disabled="submitting"
@click="form.toWalletKind = k.value; onToWalletKindChange()"
>{{ k.label }}</button>
</div>
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
<!-- 분류 CategoryPicker (이체 제외) -->
<div v-if="form.type !== 'TRANSFER'" class="field">
<span class="field-label">분류</span>
<CategoryPicker
v-model="form.category"
:type="form.type"
:categories="categories"
:disabled="submitting"
@category-added="load()"
/>
</div>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<!-- 주기 배지 -->
<div class="field">
<span class="field-label">주기</span>
<div class="type-chips">
<button
v-for="f in FREQS" :key="f.value"
type="button" class="chip" :class="{ active: form.frequency === f.value }"
:disabled="submitting"
@click="form.frequency = f.value"
>{{ f.label }}</button>
</div>
</div>
<label v-if="form.frequency === 'WEEKLY'">요일
<select v-model.number="form.dayOfWeek" :disabled="submitting">
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
</select>
</label>
<label v-if="form.frequency === 'YEARLY'">
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
</label>
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">(날짜)
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
</label>
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</AppModal>
</section> </section>
</template> </template>
@@ -454,67 +236,6 @@ button.primary { border-color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 3
.actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; } .actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; }
.msg { margin: 0.75rem 0; } .msg { margin: 0.75rem 0; }
.msg.error { color: #c0392b; } .msg.error { color: #c0392b; }
.msg.recur-limit { color: #b8860b; font-size: 0.85rem; }
/* 폼 */ .recur-upsell { color: hsla(160, 100%, 37%, 1); font-weight: 700; }
.recur-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.recur-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.recur-form label.row { flex-direction: row; align-items: center; gap: 0.4rem; }
.recur-form input,
.recur-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.recur-form label.row input { padding: 0; }
/* 배지형 선택 */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label { font-size: 0.82rem; opacity: 0.7; }
.type-chips,
.wallet-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
/* 기본 active (계좌/주기) */
.chip.active { border-color: hsla(160, 100%, 37%, 1); background: hsla(160, 100%, 37%, 1); color: #fff; }
/* 구분 배지 type별 색상 */
.chip-expense.active { border-color: #c0392b; background: #c0392b; }
.chip-income.active { border-color: #2e7d32; background: #2e7d32; }
.chip-transfer.active { border-color: #1565c0; background: #1565c0; }
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
</style> </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>
+18 -4
View File
@@ -17,6 +17,9 @@ const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
function keyOf(it) { function keyOf(it) {
return `${it.targetType}:${it.targetId}` return `${it.targetType}:${it.targetId}`
} }
function isSpam(it) {
return it.blockReason === '스팸 키워드 감지'
}
function categoryLabel(c) { function categoryLabel(c) {
return CATEGORY_LABEL[c] || c || '게시판' return CATEGORY_LABEL[c] || c || '게시판'
} }
@@ -99,8 +102,8 @@ onMounted(load)
<div class="report-admin"> <div class="report-admin">
<div class="head"> <div class="head">
<p class="desc"> <p class="desc">
신고 누적 <strong>5</strong> 이상이면 자동 블라인드됩니다. 관리자는 여기서 신고 내용을 검토해 신고 누적 <strong>5</strong> 이상 또는 스팸 키워드 포함 자동 블라인드됩니다.
<b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b> 정리할 있습니다. 관리자는 여기서 내용을 검토해 <b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b> 정리할 있습니다.
</p> </p>
<button class="reload" type="button" :disabled="loading" @click="load"> 새로고침</button> <button class="reload" type="button" :disabled="loading" @click="load"> 새로고침</button>
</div> </div>
@@ -123,7 +126,8 @@ onMounted(load)
{{ it.targetType === 'POST' ? '글' : '댓글' }} {{ it.targetType === 'POST' ? '글' : '댓글' }}
</span> </span>
<span class="cat">{{ categoryLabel(it.category) }}</span> <span class="cat">{{ categoryLabel(it.category) }}</span>
<span class="report-count">🚩 {{ it.reportCount }}</span> <span v-if="isSpam(it)" class="badge spam">🤖 스팸감지</span>
<span v-else class="report-count">🚩 {{ it.reportCount }}</span>
<span v-if="it.blocked" class="badge blind">블라인드됨</span> <span v-if="it.blocked" class="badge blind">블라인드됨</span>
<span class="time">{{ formatTime(it.lastReportedAt) }}</span> <span class="time">{{ formatTime(it.lastReportedAt) }}</span>
</div> </div>
@@ -134,6 +138,7 @@ onMounted(load)
</RouterLink> </RouterLink>
<p class="content">{{ it.content }}</p> <p class="content">{{ it.content }}</p>
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p> <p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
<p v-if="it.blockReason" class="block-reason">사유: {{ it.blockReason }}</p>
<div class="actions"> <div class="actions">
<RouterLink :to="linkTo(it)" class="btn ghost"> 보기</RouterLink> <RouterLink :to="linkTo(it)" class="btn ghost"> 보기</RouterLink>
@@ -156,7 +161,7 @@ onMounted(load)
블라인드 해제 블라인드 해제
</button> </button>
<button <button
v-if="!it.blocked" v-if="!it.blocked && !isSpam(it)"
class="btn ghost" class="btn ghost"
type="button" type="button"
:disabled="busyKey === keyOf(it)" :disabled="busyKey === keyOf(it)"
@@ -257,6 +262,15 @@ onMounted(load)
background: #e5484d22; background: #e5484d22;
color: #e5484d; color: #e5484d;
} }
.badge.spam {
background: #f9731622;
color: #ea580c;
}
.block-reason {
margin: 0 0 0.4rem;
font-size: 0.75rem;
color: #ea580c;
}
.cat { .cat {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--color-text-soft, #888); color: var(--color-text-soft, #888);
+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) { 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 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 historyOpen = ref(false)
const history = ref([]) const history = ref([])
const historyLoading = ref(false) 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 historyLoading.value = true
try { try {
history.value = await authApi.pointHistory() history.value = await authApi.pointHistory(histYear.value, histMonth.value)
} catch { } catch {
history.value = [] history.value = []
} finally { } finally {
historyLoading.value = false 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) { function fmtDateTime(s) {
if (!s) return '' if (!s) return ''
const d = new Date(s) const d = new Date(s)
@@ -126,9 +161,8 @@ async function restoreSub() {
} }
} }
// function goShop() {
function exchangeGiftcon() { router.push('/settings/shop')
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
} }
// / Android , PC ( ) // / Android , PC ( )
@@ -288,9 +322,9 @@ async function withdraw() {
<span class="point-more">내역 </span> <span class="point-more">내역 </span>
</span> </span>
</button> </button>
<button type="button" class="row row-point" @click="exchangeGiftcon"> <button type="button" class="row row-point" @click="goShop">
<span class="k">기프트콘 교환</span> <span class="k">포인트 상점</span>
<span class="v"><span class="soon">업데이트 예정</span></span> <span class="v"><span class="point-more"></span></span>
</button> </button>
</section> </section>
@@ -376,8 +410,13 @@ async function withdraw() {
<strong>포인트 적립내역</strong> <strong>포인트 적립내역</strong>
<button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button> <button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button>
</div> </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-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"> <ul v-else class="ph-list">
<li v-for="(h, i) in history" :key="i"> <li v-for="(h, i) in history" :key="i">
<div class="ph-main"> <div class="ph-main">
@@ -655,6 +694,37 @@ async function withdraw() {
line-height: 1; line-height: 1;
cursor: pointer; 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 { .ph-msg {
padding: 2rem 1rem; padding: 2rem 1rem;
text-align: center; text-align: center;
+208
View File
@@ -9,6 +9,7 @@ import { exportBackup, importBackup } from '@/utils/backup'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { appLock, PIN_LENGTH } from '@/composables/appLock' import { appLock, PIN_LENGTH } from '@/composables/appLock'
import PinPad from '@/components/PinPad.vue' import PinPad from '@/components/PinPad.vue'
import { accountApi } from '@/api/accountApi'
const dialog = useDialog() const dialog = useDialog()
const ui = useUiStore() const ui = useUiStore()
@@ -169,6 +170,55 @@ async function setDim(dim, value) {
window.desktop?.setWindowSize(w, h) 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() { async function clearAppData() {
if (clearing.value) return if (clearing.value) return
const ok = await dialog.confirm( const ok = await dialog.confirm(
@@ -342,6 +392,14 @@ async function clearAppData() {
<span class="row-value">v{{ appVersion }}</span> <span class="row-value">v{{ appVersion }}</span>
</div> </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"> <button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
<div class="row-main"> <div class="row-main">
<span class="row-label danger">App Data 삭제</span> <span class="row-label danger">App Data 삭제</span>
@@ -353,6 +411,44 @@ async function clearAppData() {
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p> <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 설정 모달 --> <!-- PIN 설정 모달 -->
<Teleport to="body"> <Teleport to="body">
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false"> <div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
@@ -564,4 +660,116 @@ async function clearAppData() {
font-size: 0.78rem; font-size: 0.78rem;
opacity: 0.5; 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> </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>